# Knowledge Base Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-kb-module.md  ·  **Slug:** kb-module  ·  **Wave:** 3
**Depends on:** customers-module, foundation-auth-rbac, system-i18n

## Goal
Deliver an internal wiki and client-facing knowledge vaults. Staff author hierarchical Tiptap v2 rich-text articles inside spaces; "vault" spaces are locked to a single `customers` row and exposed to that customer's portal users (PUBLISHED articles only). Articles support R2-backed file/image attachments served via 60-minute signed URLs, an in-page PDF viewer, drag-reorder via fractional indexing, and semantic search through the shared per-tenant Vectorize namespace (`tenant:{id}`, metadata `source='kb'`) so the AI assistant's RAG can retrieve KB content.

## Architecture
- **Package `@zync/db`** gains three tables (`kb_spaces`, `kb_articles`, `kb_attachments`) added to the Drizzle schema, plus `tenantQuery(db, tenantId).kb` and `portalQuery(db, tenantId, customerId).kb` query helpers (mirroring the existing `tenantQuery` factory from foundation-monorepo). All reads/writes are tenant-scoped; vault reads add a `customer_id` filter.
- **API (Hono Worker, `apps/zync-api`)** mounts a `/api/kb` router using `authMiddleware` + `requirePermission('kb:*')` for staff routes, and `portalQuery` for the portal read routes. File bytes live in Cloudflare **R2** (binding `STORAGE`); never exposed directly — retrieval only through a signed-URL endpoint (60-min TTL). On article create/update the route enqueues/performs a Vectorize upsert into binding `VECTORIZE`, namespace `tenant:{tenantId}`, embedding text via Workers AI binding `AI` (`@cf/baai/bge-small-en-v1.5`, 384-dim), metadata `{ source: 'kb', articleId, spaceId }`.
- **App (`apps/zync-app`, Vite+React)** renders the KB at `/kb` (spaces sidebar + article tree), read view `/kb/:spaceSlug/:articleSlug`, and editor `/kb/:spaceSlug/:articleSlug/edit`. The portal read view `/portal/:tenantSlug/kb/:spaceSlug/:articleSlug` reuses the same renderer with the DRAFT watermark and edit button hidden.
- **Upstream consumed:** `customers` (FK `customer_id` for vaults), `tenants`/`users` (tenant + author FKs), `tenant_memberships`/`roles`/`permissions` + `requirePermission`/`authMiddleware`/`requireModuleEnabled` from `@zync/auth`, `createDb`/`tenantQuery`/`buildPaginated`/`encodeCursor`/`decodeCursor` from `@zync/db`, `customer_portal_users` for portal-user → customer resolution, and i18n `translations`/`useDirection`/`t()` from `@zync/types`/i18n setup. UI primitives `Dialog`, `Form`, `FormField`, `Button`, `DataTable`, `Sheet`, `Tabs`, `Skeleton`, `EmptyState`, `Toast`/`toast`, `Breadcrumb` from `@zync/ui`.

## Tech Stack
- **Packages:** `@zync/db` (Drizzle schema + queries + migration), `@zync/types` (Zod schemas + serializers + TS types), `@zync/ui` (consumed only).
- **Apps:** `apps/zync-api` (Hono routes), `apps/zync-app` (React routes/components, Tiptap v2 editor + renderer, TanStack Query hooks).
- **Libraries:** Tiptap v2 (`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-table`, `-image`, `-underline`), `zod`, `aws4fetch` (R2 presign) or R2 `createPresignedUrl` via S3 API, fractional-index helper (NUMERIC midpoint), `react-i18next`.
- **Cloudflare bindings:** `STORAGE` (R2), `VECTORIZE` (Vectorize index), `AI` (Workers AI embeddings), Hyperdrive→Neon Postgres via `createDb(env)`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & types | 1, 2 | `packages/db/src/schema`, migration, `packages/types` | T2 after T1 |
| B — queries & shared helpers | 3, 4 | `packages/db/src/queries`, `apps/zync-api/src/lib` | T3 after T1; T4 after T1 |
| C — API routes | 5, 6, 7, 8 | `apps/zync-api/src/routes/kb.ts` | T5–T8 sequential (same file) |
| D — search indexing | 9 | `apps/zync-api/src/lib/kb-index.ts` | after T3 |
| E — app data hooks | 10 | `apps/zync-app/src/features/kb/api` | after T5–T8 |
| F — app UI | 11, 12, 13, 14 | `apps/zync-app/src/features/kb` | T11→T12/13/14 share renderer; partly parallel |
| G — portal + i18n + module wiring | 15, 16, 17 | portal route, locales, module manifest | after F |

## Tasks

### Task 1: KB database schema (Drizzle + migration)
**Blocks:** 2, 3, 4, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/kb.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export kb tables)
- Create: `packages/db/migrations/00XX_kb_module.sql`
**Steps:**
- [ ] Define `kbSpaces`, `kbArticles`, `kbAttachments` Drizzle tables matching the DDL below (uuid PKs, uuid FKs, timestamptz, boolean, jsonb).
- [ ] Add `kb:publish` permission seed value (the module-specific permission not in foundation's base list) to the permissions seed array consumed by `seedPermissions` — `kb:read`, `kb:write`, `kb:delete` already exist upstream; add `kb:publish`.
- [ ] Re-export the three tables from `packages/db/src/schema/index.ts`.
- [ ] Write the SQL migration with the exact DDL, indexes, and CHECK constraints below.
**Schema / Interfaces:**
```sql
CREATE TABLE kb_spaces (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  slug        TEXT NOT NULL,
  type        TEXT NOT NULL DEFAULT 'internal' CHECK (type IN ('internal','vault')),
  customer_id UUID REFERENCES customers(id) ON DELETE CASCADE,  -- vault only; NULL for internal
  icon        TEXT,
  is_public   BOOLEAN NOT NULL DEFAULT false,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, slug)
);
CREATE INDEX idx_kb_spaces_tenant ON kb_spaces(tenant_id);
CREATE INDEX idx_kb_spaces_customer ON kb_spaces(tenant_id, customer_id);

CREATE TABLE kb_articles (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  space_id     UUID NOT NULL REFERENCES kb_spaces(id) ON DELETE CASCADE,
  parent_id    UUID REFERENCES kb_articles(id) ON DELETE CASCADE,  -- NULL = root
  title        TEXT NOT NULL,
  slug         TEXT NOT NULL,
  content      JSONB NOT NULL,                       -- Tiptap v2 JSON
  status       TEXT NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT','PUBLISHED')),
  position     NUMERIC NOT NULL,                     -- fractional index within parent
  view_count   INTEGER NOT NULL DEFAULT 0,
  published_at TIMESTAMPTZ,
  created_by   UUID NOT NULL REFERENCES users(id),
  updated_by   UUID REFERENCES users(id),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (space_id, slug)
);
CREATE INDEX idx_kb_articles_space ON kb_articles(tenant_id, space_id);
CREATE INDEX idx_kb_articles_tree ON kb_articles(space_id, parent_id, position);

CREATE TABLE kb_attachments (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  article_id      UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE,
  filename        TEXT NOT NULL,
  r2_key          TEXT NOT NULL,
  file_type       TEXT NOT NULL,        -- MIME type
  file_size_bytes INTEGER NOT NULL,
  created_by      UUID NOT NULL REFERENCES users(id),
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_kb_attachments_article ON kb_attachments(tenant_id, article_id);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db build` and drizzle generate succeed with no type errors.
- [ ] Migration applies cleanly on Neon; all FKs are UUID→UUID; `type`/`status` CHECKs present; `(tenant_id, slug)` and `(space_id, slug)` uniques present.

### Task 2: KB Zod schemas, types & serializers
**Blocks:** 5, 6, 7, 10, 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/kb.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define and export Zod schemas: `createSpaceSchema`, `updateSpaceSchema`, `createArticleSchema`, `updateArticleSchema`, `kbSearchQuerySchema`.
- [ ] Define and export TS types: `KbSpace`, `KbArticle`, `KbArticleNode` (tree node with `children`), `KbAttachment`, `KbSearchResult`.
- [ ] Define and export serializers: `serializeKbSpace`, `serializeKbArticle` (omits internal columns, formats timestamps ISO), `serializeKbAttachment`.
- [ ] Enforce file-type allowlist constant `KB_ALLOWED_FILE_TYPES` and reject SVG.
**Schema / Interfaces:**
```ts
export const KB_ALLOWED_FILE_TYPES = [
  'application/pdf','image/jpeg','image/png','image/webp','image/gif',
  'video/mp4',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',       // XLSX
] as const;

export const createSpaceSchema = z.object({
  name: z.string().min(1).max(120),
  slug: z.string().min(1).max(120).regex(/^[a-z0-9-]+$/),
  type: z.enum(['internal','vault']).default('internal'),
  customer_id: z.string().uuid().nullable().optional(),
  icon: z.string().max(64).nullable().optional(),
  is_public: z.boolean().default(false),
  description: z.string().max(1000).nullable().optional(),
}).refine(d => d.type !== 'vault' || !!d.customer_id, {
  message: 'vault space requires customer_id', path: ['customer_id'],
});
export const updateSpaceSchema = createSpaceSchema.partial().omit({ type: true });

export const createArticleSchema = z.object({
  space_id: z.string().uuid(),
  parent_id: z.string().uuid().nullable().optional(),
  title: z.string().min(1).max(300),
  slug: z.string().min(1).max(300).regex(/^[a-z0-9-]+$/),
  content: z.record(z.any()),           // Tiptap JSON document
});
export const updateArticleSchema = z.object({
  title: z.string().min(1).max(300).optional(),
  content: z.record(z.any()).optional(),
  status: z.enum(['DRAFT','PUBLISHED']).optional(),     // publish toggle
  parent_id: z.string().uuid().nullable().optional(),   // reparent on reorder
  position: z.number().optional(),                      // fractional reorder
});
export const kbSearchQuerySchema = z.object({ q: z.string().min(1).max(200) });

export interface KbArticleNode extends KbArticle { children: KbArticleNode[] }
export interface KbSearchResult { articleId: string; spaceId: string; spaceSlug: string; articleSlug: string; title: string; score: number; }
```
**Acceptance:**
- [ ] All schemas/types/serializers exported from `@zync/types` and importable by api + app.
- [ ] `createArticleSchema`/`updateArticleSchema` reject malformed slugs; vault refine rejects missing `customer_id`.

### Task 3: KB tenant + portal query helpers
**Blocks:** 5, 6, 7, 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/kb.ts`
- Modify: `packages/db/src/queries/index.ts` (attach `.kb` to `tenantQuery` and `portalQuery` factories)
**Steps:**
- [ ] Implement staff query namespace `tenantQuery(db, tenantId).kb` with: `listSpaces()`, `getSpaceBySlug(slug)`, `createSpace(input)`, `updateSpace(id, input)`, `deleteSpace(id)` (cascade handled by FK), `listArticleTree(spaceId)`, `getArticleById(id)`, `getArticleBySlug(spaceSlug, articleSlug)`, `createArticle(input)`, `updateArticle(id, input)`, `deleteArticle(id)`, `siblingArticles(spaceId, parentId, excludeId, limit)`, `incrementViewCount(id)`, `listAttachments(articleId)`, `createAttachment(input)`, `getAttachment(id)`.
- [ ] Implement portal namespace `portalQuery(db, tenantId, customerId).kb` with: `listAccessibleSpaces()` (`type='vault' AND customer_id=$customerId`, plus `is_public=true`), `getSpaceBySlug(slug)` (same filter), `listPublishedArticleTree(spaceId)` (`status='PUBLISHED'`), `getPublishedArticleBySlug(spaceSlug, articleSlug)`, `listAttachments(articleId)`, `getAttachment(id)` — every method asserts the parent space is customer-accessible.
- [ ] Every method filters on `tenant_id`; portal methods additionally enforce `customer_id`/`is_public` and `status='PUBLISHED'`.
- [ ] `listArticleTree`/`listPublishedArticleTree` order children by `position ASC` and build the `KbArticleNode` tree in memory.
**Schema / Interfaces:**
```ts
// usage shapes
tenantQuery(db, tenantId).kb.listArticleTree(spaceId): Promise<KbArticleNode[]>
portalQuery(db, tenantId, customerId).kb.getPublishedArticleBySlug(spaceSlug, articleSlug): Promise<KbArticle | null>
```
**Acceptance:**
- [ ] No raw Drizzle access from routes — all KB DB access flows through these helpers (`no-raw-drizzle-from-routes`).
- [ ] Portal helpers never return DRAFT articles nor spaces whose `customer_id` ≠ the portal user's customer.

### Task 4: R2 signing & file-validation utility
**Blocks:** 7, 8  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/lib/kb-storage.ts`
**Steps:**
- [ ] Implement `kbR2Key(tenantId, articleId, attachmentId, filename)` → deterministic key `kb/{tenantId}/{articleId}/{attachmentId}/{safeFilename}`.
- [ ] Implement `putKbObject(env, key, body, contentType)` writing to binding `STORAGE`.
- [ ] Implement `signKbUrl(env, key, ttlSeconds = 3600)` returning a presigned GET URL (60-min TTL) via R2 S3 presign (`aws4fetch` against the R2 S3 endpoint, or `STORAGE.createPresignedUrl` if available in runtime).
- [ ] Implement `assertAllowedFileType(mime)` — reject anything not in `KB_ALLOWED_FILE_TYPES`; explicitly reject `image/svg+xml` (XSS). Throw `ApiError` 415 on rejection.
**Schema / Interfaces:**
```ts
export function kbR2Key(tenantId: string, articleId: string, attachmentId: string, filename: string): string
export async function putKbObject(env: Env, key: string, body: ReadableStream | ArrayBuffer, contentType: string): Promise<void>
export async function signKbUrl(env: Env, key: string, ttlSeconds?: number): Promise<string>  // default 3600
export function assertAllowedFileType(mime: string): void  // throws ApiError(415) if disallowed
```
**Acceptance:**
- [ ] R2 keys are never returned to clients; only signed URLs are.
- [ ] SVG and any non-allowlisted MIME upload is rejected with 415.
- [ ] Signed URL expires at 60 minutes.

### Task 5: KB spaces API routes
**Blocks:** 6, 10  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/kb.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `app.route('/api/kb', kbRouter)`)
**Steps:**
- [ ] Apply `authMiddleware` and `requireModuleEnabled('kb')` to the whole staff router.
- [ ] `GET /api/kb/spaces` — `requirePermission('kb:read')` → `tenantQuery(db,t).kb.listSpaces()`, serialize, group internal vs vault in response order (internal first).
- [ ] `POST /api/kb/spaces` — `requirePermission('kb:write')`, validate `createSpaceSchema`; if `type='vault'` verify `customer_id` belongs to tenant; insert; return serialized space.
- [ ] `PATCH /api/kb/spaces/:id` — `requirePermission('kb:write')`, validate `updateSpaceSchema`, update, return serialized.
- [ ] `DELETE /api/kb/spaces/:id` — `requirePermission('kb:write')`, delete (articles + attachments cascade via FK); on success enqueue/perform Vectorize delete for all article vectors of that space (`source='kb'`, `spaceId`).
- [ ] All inputs validated with Zod (`require-zod-validation-in-routes`).
**Schema / Interfaces:**
```
GET    /api/kb/spaces        (kb:read)   → KbSpace[]
POST   /api/kb/spaces        (kb:write)  → KbSpace
PATCH  /api/kb/spaces/:id    (kb:write)  → KbSpace
DELETE /api/kb/spaces/:id    (kb:write)  → { ok: true }
```
**Acceptance:**
- [ ] Vault create rejects a `customer_id` not owned by the tenant (404/422).
- [ ] Deleting a space cascades articles + attachments and removes their Vectorize vectors.

### Task 6: KB article CRUD + tree + view-count routes
**Blocks:** 9, 10  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-api/src/routes/kb.ts`
- Create: `apps/zync-api/src/lib/fractional-index.ts` (NUMERIC midpoint helper)
**Steps:**
- [ ] `GET /api/kb/spaces/:id/articles` — `kb:read` → `listArticleTree(spaceId)` as `KbArticleNode[]`.
- [ ] `POST /api/kb/articles` — `kb:write`, validate `createArticleSchema`; compute `position` via `fractionalIndexAfter(lastSiblingPosition)`; set `status='DRAFT'`, `created_by=session.userId`; insert; trigger Vectorize upsert (Task 9). Return serialized article.
- [ ] `GET /api/kb/articles/:id` — `kb:read` → article + content; staff can read DRAFT and PUBLISHED.
- [ ] `PATCH /api/kb/articles/:id` — validate `updateArticleSchema`. Content/title edits require `kb:write`; setting `status='PUBLISHED'`/back to `'DRAFT'` requires `kb:publish` (set/clear `published_at`); `position`/`parent_id` reorder requires `kb:write` and recomputes fractional position. Set `updated_by`/`updated_at`. Trigger Vectorize upsert.
- [ ] `DELETE /api/kb/articles/:id` — `requirePermission('kb:delete')`; delete (children cascade); remove vectors.
- [ ] `PUT /api/kb/articles/:id/view` — `kb:read`; fire-and-forget `incrementViewCount(id)` (non-transactional, `c.executionCtx.waitUntil`); return 204.
**Schema / Interfaces:**
```
GET    /api/kb/spaces/:id/articles   (kb:read)    → KbArticleNode[]
POST   /api/kb/articles              (kb:write)   → KbArticle
GET    /api/kb/articles/:id          (kb:read)    → KbArticle
PATCH  /api/kb/articles/:id          (kb:write / kb:publish for status) → KbArticle
DELETE /api/kb/articles/:id          (kb:delete)  → { ok: true }
PUT    /api/kb/articles/:id/view     (kb:read)    → 204

// fractional-index.ts
export function fractionalIndexAfter(prev: number | null): number   // prev==null → 1
export function fractionalIndexBetween(a: number, b: number): number // (a+b)/2
```
**Acceptance:**
- [ ] Publish toggle is gated by `kb:publish`; a user with only `kb:write` cannot publish.
- [ ] Reorder updates `position` without renumbering siblings; tree returns children sorted by `position`.
- [ ] View increment never blocks the response and is not part of any DB transaction.

### Task 7: Attachment upload + list routes
**Blocks:** 8  ·  **Blocked by:** 4, 5
**Files:**
- Modify: `apps/zync-api/src/routes/kb.ts`
**Steps:**
- [ ] `POST /api/kb/articles/:id/attachments` — `kb:write`; parse multipart; `assertAllowedFileType(file.type)`; generate attachment id; `putKbObject` to R2 with `kbR2Key(...)`; insert `kb_attachments` row (`file_size_bytes`, `file_type`, `created_by`); return serialized attachment (no `r2_key`).
- [ ] `GET /api/kb/articles/:id/attachments` — `kb:read` → list serialized attachments (no `r2_key`).
- [ ] Enforce a max upload size (e.g. 25 MB) returning 413 when exceeded.
**Schema / Interfaces:**
```
POST /api/kb/articles/:id/attachments  (kb:write)  multipart → KbAttachment
GET  /api/kb/articles/:id/attachments  (kb:read)   → KbAttachment[]
```
**Acceptance:**
- [ ] Upload of `image/svg+xml` returns 415; allowlisted types succeed.
- [ ] Response objects never include `r2_key`.

### Task 8: Signed attachment URL route (staff + portal)
**Blocks:** —  ·  **Blocked by:** 4, 7
**Files:**
- Modify: `apps/zync-api/src/routes/kb.ts` (staff)
- Create: `apps/zync-api/src/routes/portal-kb.ts` (portal read surface)
- Modify: `apps/zync-api/src/index.ts` (mount portal router)
**Steps:**
- [ ] Staff `GET /api/kb/attachments/:id/url` — `kb:read`; load attachment via `tenantQuery`; `signKbUrl(env, attachment.r2_key, 3600)`; return `{ url, expiresAt }`.
- [ ] Portal router (`/portal/:tenantSlug/api/kb`, or the existing portal API mount) uses the portal session → resolve `customer_id` via `customer_portal_users`, build `portalQuery(db, tenantId, customerId)`. Implement: `GET .../spaces`, `GET .../spaces/:id/articles` (published tree), `GET .../articles/:id` (published only), `GET .../attachments/:id/url` (signed URL, only if parent article is PUBLISHED in an accessible vault).
- [ ] Portal attachment URL route enforces the same `customer_id`/PUBLISHED checks before signing.
**Schema / Interfaces:**
```
GET /api/kb/attachments/:id/url                 (kb:read)        → { url: string, expiresAt: string }
GET /portal/.../api/kb/spaces                   (portal session) → KbSpace[]   (accessible vaults)
GET /portal/.../api/kb/spaces/:id/articles      (portal session) → KbArticleNode[] (PUBLISHED)
GET /portal/.../api/kb/articles/:id             (portal session) → KbArticle  (PUBLISHED)
GET /portal/.../api/kb/attachments/:id/url      (portal session) → { url, expiresAt }
```
**Acceptance:**
- [ ] A portal user cannot obtain a signed URL for an attachment in a vault belonging to another customer (403/404).
- [ ] Portal cannot fetch DRAFT articles.

### Task 9: Vectorize indexing on save + search route
**Blocks:** 10  ·  **Blocked by:** 3, 6
**Files:**
- Create: `apps/zync-api/src/lib/kb-index.ts`
- Modify: `apps/zync-api/src/routes/kb.ts` (search route + upsert/delete calls)
**Steps:**
- [ ] `extractKbPlainText(content)` — flatten Tiptap JSON to plain text (concatenate text nodes; include headings) for embedding.
- [ ] `upsertKbVector(env, { tenantId, article })` — embed text via Workers AI binding `AI.run('@cf/baai/bge-small-en-v1.5', { text })`; `env.VECTORIZE.upsert([{ id: 'kb:'+article.id, namespace: 'tenant:'+tenantId, values, metadata: { source: 'kb', articleId: article.id, spaceId: article.space_id } }])`. Call from article create/update (via `c.executionCtx.waitUntil`).
- [ ] `deleteKbVector(env, tenantId, articleId)` — `env.VECTORIZE.deleteByIds(['kb:'+articleId])`; call on article/space delete.
- [ ] `GET /api/kb/search?q=` — `kb:read`, validate `kbSearchQuerySchema`; embed query; `env.VECTORIZE.query(vector, { namespace: 'tenant:'+tenantId, topK: 10, filter: { source: 'kb' } })`; map matches to `KbSearchResult` (resolve `spaceSlug`/`articleSlug` via `tenantQuery`); drop articles the caller can't see; return ranked list.
**Schema / Interfaces:**
```ts
export function extractKbPlainText(content: unknown): string
export async function upsertKbVector(env: Env, args: { tenantId: string; article: KbArticle }): Promise<void>
export async function deleteKbVector(env: Env, tenantId: string, articleId: string): Promise<void>
// GET /api/kb/search?q=...   (kb:read) → KbSearchResult[]
```
**Acceptance:**
- [ ] Vectors land in namespace `tenant:{tenantId}` with metadata `source:'kb'` so the AI assistant RAG (filtering `source='kb'`) retrieves them and unfiltered RAG also sees them.
- [ ] Search returns only articles within the caller's tenant; indexing is real-time on save (no cron).

### Task 10: App KB data hooks (TanStack Query)
**Blocks:** 11, 12, 13, 14  ·  **Blocked by:** 5, 6, 9
**Files:**
- Create: `apps/zync-app/src/features/kb/api/useKbSpaces.ts`
- Create: `apps/zync-app/src/features/kb/api/useKbArticles.ts`
- Create: `apps/zync-app/src/features/kb/api/useKbAttachments.ts`
- Create: `apps/zync-app/src/features/kb/api/useKbSearch.ts`
**Steps:**
- [ ] `useKbSpaces()` (list), `useCreateSpace()`, `useUpdateSpace()`, `useDeleteSpace()` mutations with cache invalidation.
- [ ] `useArticleTree(spaceId)`, `useKbArticle(id)`, `useCreateArticle()`, `useUpdateArticle()` (used by debounced auto-save), `usePublishArticle()`, `useReorderArticle()`, `useDeleteArticle()`.
- [ ] `useKbAttachments(articleId)`, `useUploadAttachment(articleId)`, `useAttachmentUrl(id)` (lazy fetch on click), `useTrackView(id)` (fire-and-forget `PUT .../view`).
- [ ] `useKbSearch(q)` with debounce.
**Acceptance:**
- [ ] Mutations invalidate the relevant query keys; auto-save mutation is debounced upstream by the editor.

### Task 11: Tiptap renderer + editor components
**Blocks:** 12, 13, 14  ·  **Blocked by:** 10
**Files:**
- Create: `apps/zync-app/src/features/kb/components/ArticleRenderer.tsx`
- Create: `apps/zync-app/src/features/kb/components/ArticleEditor.tsx`
- Create: `apps/zync-app/src/features/kb/components/EditorToolbar.tsx`
- Create: `apps/zync-app/src/features/kb/components/PdfEmbed.tsx`
**Steps:**
- [ ] `ArticleRenderer` renders Tiptap JSON read-only (StarterKit + table + image + underline extensions); images resolve via signed URL.
- [ ] `ArticleEditor` full-page editor; toolbar: headings, bold/italic/underline, lists, blockquote, code block, divider, table, image/file attach. Paste/drag image → `useUploadAttachment` → insert signed-URL image node. File attach → upload → insert download-link node + `kb_attachments` record.
- [ ] Auto-save: Tiptap `onUpdate` → debounce 2s → `useUpdateArticle`. Render a save indicator with states "Unsaved changes" / "Saving…" / "Saved" (use `aria-live="polite"`).
- [ ] `PdfEmbed` renders `<object data={signedUrl} type="application/pdf">` with a download-link fallback for browsers without native PDF rendering (mobile).
- [ ] Respect `prefers-reduced-motion` for any smooth-scroll/animation; respect RTL via `useDirection`.
**Acceptance:**
- [ ] Pasting an image uploads to R2 and embeds via signed URL (never inline base64).
- [ ] PDF attachments render in-page with a working mobile download fallback.
- [ ] Save indicator updates within ~2s of an edit and is announced to screen readers.

### Task 12: Spaces sidebar + article tree + new-space modal (`/kb`)
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Create: `apps/zync-app/src/features/kb/KbLayout.tsx`
- Create: `apps/zync-app/src/features/kb/components/SpacesSidebar.tsx`
- Create: `apps/zync-app/src/features/kb/components/ArticleTree.tsx`
- Create: `apps/zync-app/src/features/kb/components/NewSpaceModal.tsx`
- Modify: `apps/zync-app/src/router.tsx` (register `/kb` and child routes)
**Steps:**
- [ ] `SpacesSidebar`: internal spaces at top, vault spaces grouped by customer below (icon + name); "New space" button.
- [ ] `NewSpaceModal` (`Dialog` + `Form`): name, slug, type (internal/vault), customer select (shown only when vault; populated from customers query), icon picker; calls `useCreateSpace`.
- [ ] `ArticleTree`: hierarchical parent→child tree; drag-reorder within same parent → `useReorderArticle` (optimistic, fractional position); keyboard-accessible (roving tabindex, `aria` tree roles).
- [ ] Manual search bar in sidebar wired to `useKbSearch`; results link to read view.
- [ ] Empty states via `EmptyState`; loading via `Skeleton`.
**Acceptance:**
- [ ] Vault spaces are grouped under their customer; customer select only appears for vault type.
- [ ] Drag-reorder persists and survives reload; tree is keyboard navigable with correct aria roles.

### Task 13: Article read view (`/kb/:spaceSlug/:articleSlug`)
**Blocks:** 15  ·  **Blocked by:** 11
**Files:**
- Create: `apps/zync-app/src/features/kb/ArticleReadPage.tsx`
- Create: `apps/zync-app/src/features/kb/components/ArticleToc.tsx`
- Create: `apps/zync-app/src/features/kb/components/AttachmentsFooter.tsx`
- Create: `apps/zync-app/src/features/kb/components/RelatedArticles.tsx`
**Steps:**
- [ ] Breadcrumbs: space name → parent article title (if nested) → current title, each crumb a link (`Breadcrumb` primitive).
- [ ] `ArticleReadPage` renders `ArticleRenderer`; calls `useTrackView` on mount (staff only).
- [ ] `ArticleToc`: extract h1/h2/h3 from Tiptap JSON; sticky on scroll; shown only if ≥ 3 headings; clicking smooth-scrolls (respect `prefers-reduced-motion` → instant jump). Headings get ids; TOC links are real anchors with `aria-current`.
- [ ] `RelatedArticles`: sibling articles (same parent), max 5, ordered by `position`.
- [ ] `AttachmentsFooter`: list `kb_attachments` with type icon, filename, size; click → `useAttachmentUrl` fetches signed URL then opens/downloads; PDFs open via `PdfEmbed`.
- [ ] DRAFT banner: when `status='DRAFT'` show "Draft — not visible to portal" banner (role `status`); staff can still view.
- [ ] Edit button: visible only to users with `kb:write` → navigates to `/edit` route.
**Acceptance:**
- [ ] TOC appears only with ≥3 headings, is sticky, and smooth-scroll is suppressed under reduced-motion.
- [ ] DRAFT banner shows for drafts; edit button hidden without `kb:write`.
- [ ] Attachment links fetch a fresh signed URL on click; PDFs render in-page.

### Task 14: Article editor route (`/kb/:spaceSlug/:articleSlug/edit`)
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Create: `apps/zync-app/src/features/kb/ArticleEditPage.tsx`
**Steps:**
- [ ] Guard route with `kb:write`; render `ArticleEditor` + `EditorToolbar`.
- [ ] Publish/unpublish toggle gated by `kb:publish` (`usePublishArticle`); show view count in a settings panel.
- [ ] Wire auto-save indicator from Task 11; on title change update slug uniqueness gracefully (server enforces `(space_id, slug)`).
**Acceptance:**
- [ ] Users without `kb:write` are redirected/403 from the edit route.
- [ ] Publish toggle disabled/hidden without `kb:publish`.

### Task 15: Portal KB read view (`/portal/:tenantSlug/kb/...`)
**Blocks:** —  ·  **Blocked by:** 8, 13
**Files:**
- Create: `apps/zync-app/src/features/portal/kb/PortalKbPage.tsx`
- Create: `apps/zync-app/src/features/portal/kb/PortalArticlePage.tsx`
- Modify: portal router registration
**Steps:**
- [ ] Reuse `ArticleRenderer`, `ArticleToc`, `AttachmentsFooter`, `RelatedArticles` against the portal API endpoints (Task 8).
- [ ] Hide DRAFT watermark and edit button entirely in portal mode.
- [ ] Spaces list shows only the customer's accessible vaults (and public spaces); articles list shows PUBLISHED only.
- [ ] Attachment downloads use the portal signed-URL endpoint.
**Acceptance:**
- [ ] Portal user sees only their vault's PUBLISHED articles; no DRAFT watermark, no edit button.
- [ ] Cross-customer access attempts fail at the API (403/404).

### Task 16: i18n strings (English + Hebrew) for KB
**Blocks:** —  ·  **Blocked by:** 12, 13, 14, 15
**Files:**
- Modify: `packages/i18n/locales/en/kb.json` (create)
- Modify: `packages/i18n/locales/he/kb.json` (create)
- Modify: i18n namespace registration / `translations` index
**Steps:**
- [ ] Add all KB user-visible strings (space/article actions, save states, draft banner, TOC, related, attachments, search placeholder, modal labels, permission errors) as `t('kb_...')` keys.
- [ ] Provide complete Hebrew translations for every key (PRs without Hebrew translations are blocked).
- [ ] Verify RTL layout via `useDirection` in the KB layout, sidebar, breadcrumbs, and editor toolbar.
**Acceptance:**
- [ ] No hardcoded English strings in KB JSX — all via `t()`.
- [ ] Every new English key has a matching Hebrew key; UI mirrors correctly in RTL.

### Task 17: Module manifest + cron-free wiring
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `packages/config` module manifest (add/confirm `kb` `ModuleDefinition` in `MODULE_MANIFEST`)
- Modify: nav/route registration for `/kb`
**Steps:**
- [ ] Confirm the `kb` `ModuleId` exists in `MODULE_MANIFEST` / `MODULE_BY_ID`; if defining here, set id `'kb'`, name, icon, and `requireModuleEnabled('kb')` gate on routes.
- [ ] Register the `kb:publish` permission with `seedPermissions` and attach it to system roles that already hold `kb:write` (OWNER/ADMIN per foundation role matrix).
- [ ] Add `/kb` to the app navigation (visible with `kb:read`).
**Acceptance:**
- [ ] Disabling the KB module hides `/kb` nav and returns module-disabled from `/api/kb/*` via `requireModuleEnabled('kb')`.
- [ ] `kb:publish` is seeded and granted to appropriate system roles.
