# KB Article Versioning — Implementation Plan

**Spec:** docs/specs/2026-05-31-kb-versioning.md  ·  **Slug:** kb-versioning  ·  **Wave:** 13
**Depends on:** foundation-auth-rbac, kb-article-editor, kb-module, operational-audit-trail

## Goal
Add full version history to KB articles. Every explicit save (the `[Save & version]` toolbar button), every publish, and every restore writes an immutable JSONB snapshot of the article's title + Tiptap content into a new `kb_article_versions` table. Staff can browse paginated version history, diff any past version against the current article (text-level and rendered HTML), and restore a prior version — with the current state auto-snapshotted first so nothing is lost. The feature is gated to the Business+ tier.

## Architecture
This spec is a delta on top of `kb-module` (defines `kb_articles`: `id`, `tenant_id`, `space_id`, `title`, `content` JSONB Tiptap, `status`, `updated_at`, `updated_by`, `created_by`) and `kb-article-editor` (defines the editor UI, `PATCH /api/kb/articles/:id`, `POST /api/kb/articles/:id/publish`, the `validateTiptapContent` / `renderArticleContent` server helpers, and `extractPlainText` for plain-text extraction).

Data flow:
- A new append-only table `kb_article_versions` holds full snapshots. `version_number` is assigned server-side as `MAX(version_number)+1` per article inside the same transaction as the insert; the `UNIQUE (article_id, version_number)` constraint is the concurrency guard.
- New routes under `apps/zync-api` create/list/read/restore versions. Every route gates on `requireModuleEnabled('kb')`, `requirePermission('kb:read'|'kb:write')`, and the Business+ tier via `meetsMinimumTier` / `requireTier`.
- Version creation is also wired into existing kb-article-editor surfaces: the publish route snapshots before the status change, and the editor toolbar gains a `[Save & version]` button (save-then-snapshot) plus a History tab in the right sidebar.
- Restore runs as one transaction: snapshot current → overwrite `kb_articles.content`/`title`/`updated_at`/`updated_by` → insert a `tenant_audit_log` row (`event_type: 'kb_article.restored'`) — honoring the `require-audit-in-transaction` rule from `operational-audit-trail`.
- Diff: convert both Tiptap JSON snapshots to plain text via `extractPlainText`, line-diff with the `diff` npm package; rendered HTML side-by-side via the existing `renderArticleContent` (`generateHTML(content, extensions)`).

It consumes upstream: tables `kb_articles`, `users`, `tenant_audit_log`; exports `requirePermission`, `requireModuleEnabled`, `requireTier`, `meetsMinimumTier`, `buildPaginated`, `clampLimit`, `extractPlainText`, `renderArticleContent`, `validateTiptapContent`.

## Tech Stack
- **`@zync/db`** (Drizzle ORM, Neon Postgres via Cloudflare Hyperdrive): new `kbArticleVersions` table + SQL migration.
- **`apps/zync-api`** (Hono on Cloudflare Workers): 4 new routes + edits to the existing publish route. Zod request validation (`require-zod-validation-in-routes`).
- **`apps/zync-app`** (Vite + React): History tab, diff view, restore confirmation Dialog, save-version label input — reusing `@zync/ui` (`Dialog`, `Button`, `Input`, `Tabs`, `Spinner`, `EmptyState`) and `LocaleProvider`/`useDirection` for RTL + i18n.
- **Libraries:** `diff` (npm) for text diff; `@tiptap/html` `generateHTML` (already a dep via kb-article-editor) for HTML rendering.
- **Bindings:** Hyperdrive (Postgres) only; no new KV/R2/Queue bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Schema | 1 | `packages/db/src/schema/kb.ts`, `packages/db/migrations/*` | No (blocks all) |
| B — Server core | 2, 3 | `apps/zync-api/src/lib/kb-versions.ts` | After A; 2 then 3 |
| C — Routes | 4, 5, 6, 7 | `apps/zync-api/src/routes/kb/versions.ts` | After B; parallel among themselves |
| D — Editor hooks | 8, 9 | `apps/zync-api/src/routes/kb/articles.ts`, editor UI | After C |
| E — Frontend | 10, 11, 12, 13 | `apps/zync-app/src/features/kb/*` | After C; 10 first, then 11/12/13 parallel |

## Tasks

### Task 1: Drizzle schema + migration for `kb_article_versions`
**Blocks:** 2,3,4,5,6,7,8  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/kb.ts`
- Create: `packages/db/migrations/00XX_kb_article_versions.sql`
**Steps:**
- [ ] Add a `kbArticleVersions` Drizzle table matching the DDL below (uuid PK default `gen_random_uuid()`, `article_id` FK to `kb_articles` with `onDelete: 'cascade'`, `created_by` FK to `users`, `tenant_id` bare UUID NOT NULL to match the upstream `kb_articles` pattern, no FK).
- [ ] Add the unique constraint `UNIQUE (article_id, version_number)` and the descending index.
- [ ] Generate/author the raw SQL migration with the canonical dialect (`created_at TIMESTAMPTZ NOT NULL DEFAULT now()` — add NOT NULL beyond the spec's bare default).
- [ ] Export `kbArticleVersions` from the db package index alongside the existing `kb*` exports.
**Schema / Interfaces:**
```sql
CREATE TABLE kb_article_versions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE,
  tenant_id UUID NOT NULL,
  version_number INTEGER NOT NULL,        -- auto-increment per article
  title TEXT NOT NULL,
  content JSONB NOT NULL,                 -- full Tiptap JSON snapshot
  label TEXT,                             -- optional user-provided label
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (article_id, version_number)
);

CREATE INDEX idx_kb_article_versions_article
  ON kb_article_versions(article_id, version_number DESC);
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon Postgres; `\d kb_article_versions` shows the unique constraint and index.
- [ ] Deleting a `kb_articles` row cascades and removes its versions.

### Task 2: `createArticleVersion` helper (snapshot writer)
**Blocks:** 4,5,8  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/lib/kb-versions.ts`
**Steps:**
- [ ] Implement `createArticleVersion(tx, { articleId, tenantId, title, content, label, createdBy })` that reads the persisted article snapshot (title + content) is the caller's responsibility — this helper writes exactly what it is handed.
- [ ] Compute `version_number` inside the passed transaction as `SELECT COALESCE(MAX(version_number),0)+1 FROM kb_article_versions WHERE article_id = :articleId`.
- [ ] Insert the row and `RETURNING` it.
- [ ] Concurrency guard: callers wrap the call in a serializable/`tx` block and retry once on a `23505` (unique violation) on `(article_id, version_number)`; document this on the helper. Provide `createArticleVersionWithRetry(db, args)` wrapping a fresh tx with a single retry.
**Schema / Interfaces:**
```ts
interface CreateArticleVersionArgs {
  articleId: string;
  tenantId: string;
  title: string;
  content: unknown;        // Tiptap JSON (already validated upstream)
  label: string | null;
  createdBy: string;
}
export async function createArticleVersion(
  tx: DbTx, args: CreateArticleVersionArgs,
): Promise<KbArticleVersion>;
export async function createArticleVersionWithRetry(
  db: Db, args: CreateArticleVersionArgs,
): Promise<KbArticleVersion>;
```
**Acceptance:**
- [ ] Two concurrent calls for the same article never both succeed with the same `version_number`; the loser retries and lands the next number.
- [ ] Returned row carries the assigned `version_number`.

### Task 3: Diff computation helpers
**Blocks:** 11  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/lib/kb-versions.ts`
**Steps:**
- [ ] Add `diff` to `apps/zync-api` dependencies.
- [ ] Implement `computeTextDiff(oldContent, newContent)` — run both through `extractPlainText`, then `diffLines` from the `diff` package; return an array of `{ value, added?, removed? }` segments.
- [ ] Implement `renderVersionHtml(content)` reusing `renderArticleContent` (`generateHTML(content, extensions)`) for the side-by-side HTML comparison.
- [ ] These helpers are pure (no DB) so they can run server-side for the diff response payload.
**Schema / Interfaces:**
```ts
export interface DiffSegment { value: string; added?: boolean; removed?: boolean; }
export function computeTextDiff(oldContent: unknown, newContent: unknown): DiffSegment[];
export function renderVersionHtml(content: unknown): string; // sanitized HTML
```
**Acceptance:**
- [ ] `computeTextDiff` of identical content returns a single segment with no `added`/`removed` flags.
- [ ] Removed lines carry `removed: true`, added lines `added: true`.
- [ ] Hebrew/RTL text passes through `extractPlainText` and diffs by line without mangling.

### Task 4: `GET /api/kb/articles/:id/versions` (list, paginated)
**Blocks:** 10  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/kb/versions.ts`
**Steps:**
- [ ] Register the route; apply middleware chain: `authMiddleware`, `requireModuleEnabled('kb')`, `requireTier('BUSINESS')` (Business+ gate via `meetsMinimumTier`), `requirePermission('kb:read')`.
- [ ] Zod-validate query `{ page?: number >= 1 (default 1), limit?: number (default 20) }`; clamp with `clampLimit` (max 20 per spec — keep 20).
- [ ] Verify the article belongs to the caller's tenant (404 if not).
- [ ] Query versions for the article ordered `version_number DESC`, offset/limit by page; return list WITHOUT the `content` JSONB; join `users` for `created_by` display name.
- [ ] Wrap with `buildPaginated` (page-based).
**Schema / Interfaces:**
```
GET /api/kb/articles/:id/versions
  query: { page?: number = 1, limit?: number = 20 }
  200 → PaginatedResponse<{
    id: string; version_number: number; title: string;
    label: string | null; created_by: { id: string; name: string };
    created_at: string;   // ISO TIMESTAMPTZ
  }>
  Requires: kb:read, module 'kb' enabled, tier >= BUSINESS
```
**Acceptance:**
- [ ] Returns 20 items max per page, newest `version_number` first.
- [ ] Free/Starter tier tenant receives 403 from the tier gate.
- [ ] Response omits `content`.

### Task 5: `POST /api/kb/articles/:id/versions` (create snapshot)
**Blocks:** 8,9  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/kb/versions.ts`
**Steps:**
- [ ] Middleware chain with `requirePermission('kb:write')` and Business+ tier gate.
- [ ] Zod-validate body `{ label?: string (max 200) }`.
- [ ] Load the **persisted** article row (title + content) within the tenant; 404 if missing.
- [ ] Call `createArticleVersionWithRetry` with the persisted title/content, the supplied label (or null), `createdBy = session.userId`.
- [ ] Return the created version metadata (no content).
- [ ] Note for callers: `[Save & version]` = PATCH-save first, THEN POST here, so the snapshot reflects saved (not stale) content. This route always snapshots the persisted state.
**Schema / Interfaces:**
```
POST /api/kb/articles/:id/versions
  body: { label?: string }
  201 → { id, version_number, title, label, created_by, created_at }
  Requires: kb:write, module 'kb' enabled, tier >= BUSINESS
```
**Acceptance:**
- [ ] Creating a version increments `version_number` by exactly 1 over the prior max.
- [ ] Empty/omitted label stores `NULL`.

### Task 6: `GET /api/kb/articles/:id/versions/:versionId` (full version)
**Blocks:** 11  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/kb/versions.ts`
**Steps:**
- [ ] Middleware chain with `requirePermission('kb:read')` and Business+ tier gate.
- [ ] Verify version belongs to the article and the article belongs to the tenant (404 otherwise).
- [ ] Return the full version INCLUDING `content` JSONB.
**Schema / Interfaces:**
```
GET /api/kb/articles/:id/versions/:versionId
  200 → { id, version_number, title, content, label, created_by, created_at }
  Requires: kb:read, module 'kb' enabled, tier >= BUSINESS
```
**Acceptance:**
- [ ] Returns the exact stored Tiptap JSON snapshot.
- [ ] Cross-tenant or mismatched-article version IDs return 404.

### Task 7: `POST /api/kb/articles/:id/versions/:versionId/restore`
**Blocks:** 12  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/kb/versions.ts`
**Steps:**
- [ ] Middleware chain with `requirePermission('kb:write')` and Business+ tier gate.
- [ ] Resolve the target version and current article inside one DB transaction (verify tenant + article match; 404 otherwise).
- [ ] In the transaction, in this exact order:
  1. Snapshot current article state as a new version via `createArticleVersion`, auto-label `"Auto-saved before restore to v{N}"` (N = target version_number), `createdBy = session.userId`.
  2. Overwrite `kb_articles.content` and `kb_articles.title` with the target version's snapshot; set `updated_at = now()`, `updated_by = session.userId`.
  3. Insert the audit row in the SAME transaction (see Schema) — `require-audit-in-transaction`.
- [ ] On unique-violation (`23505`) during step 1, retry the whole tx once.
- [ ] Return the new current article state + the safety version metadata.
**Schema / Interfaces:**
```ts
// Same-transaction audit insert (operational-audit-trail / tenant_audit_log shape):
await tx.insert(tenant_audit_log).values({
  tenant_id: tenantId,
  actor_id: session.userId,
  event_type: 'kb_article.restored',
  entity_type: 'kb_article',
  entity_id: articleId,
  before_state: { from_version: currentMaxVersion },   // e.g. 8
  after_state:  { to_version: targetVersionNumber },   // e.g. 6
});
```
```
POST /api/kb/articles/:id/versions/:versionId/restore
  200 → { article: { id, title, content, status, updated_at }, safety_version: { id, version_number } }
  Requires: kb:write, module 'kb' enabled, tier >= BUSINESS
```
**Acceptance:**
- [ ] After restore, `kb_articles.content`/`title` equal the target version's snapshot.
- [ ] A safety version exists labelled `"Auto-saved before restore to v{N}"` capturing the pre-restore state.
- [ ] Exactly one `tenant_audit_log` row with `event_type='kb_article.restored'`, `entity_type='kb_article'`, and `{from_version,to_version}` is written in the same transaction; if the audit insert fails the content overwrite rolls back.

### Task 8: Wire version creation into the publish route
**Blocks:** —  ·  **Blocked by:** 2,5
**Files:**
- Modify: `apps/zync-api/src/routes/kb/articles.ts`
**Steps:**
- [ ] In the existing `POST /api/kb/articles/:id/publish` handler, before applying the status change, call `createArticleVersion` (or `createArticleVersionWithRetry`) inside the publish transaction to snapshot the current persisted title/content, auto-labelling nothing (label NULL) — the spec only requires a version exists pre-publish.
- [ ] Ensure the snapshot uses the persisted content (publish does not change content, only `status`/`published_at`).
**Acceptance:**
- [ ] Every publish produces a new version row reflecting the article state at publish time.
- [ ] Publish still sets `status='PUBLISHED'`, `published_at=now()` unchanged.

### Task 9: `[Save & version]` toolbar action + label input (editor)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/kb/editor/EditorToolbar.tsx`
- Create: `apps/zync-app/src/features/kb/versions/SaveVersionDialog.tsx`
**Steps:**
- [ ] Add a `[Save & version]` button to the editor toolbar (next to `[Save draft]`), shown only when `useTierGate('BUSINESS')` passes and the user has `kb:write`.
- [ ] On click, open `SaveVersionDialog` (a `@zync/ui` `Dialog`) with an optional label `Input` ("Label (optional)") and helper text examples; buttons `[Cancel]` / `[Save version]`.
- [ ] On confirm: first PATCH-save the current editor title+content via the existing save mutation, then `POST /api/kb/articles/:id/versions` with `{ label }`. Show a toast on success; invalidate the version-list query.
- [ ] Dialog: focus-trap + `aria-modal`, label associated via `htmlFor`, all strings via i18n `translations`, honor `useDirection` for RTL, honor `prefers-reduced-motion` for the dialog transition.
**Schema / Interfaces:**
```ts
export function SaveVersionDialog(props: {
  articleId: string; open: boolean; onClose: () => void;
}): JSX.Element;
```
**Acceptance:**
- [ ] Saving with a label persists it; the new version appears at the top of the History tab.
- [ ] Save-then-snapshot order verified: editing content then `[Save & version]` snapshots the just-saved content, not the last autosave.
- [ ] Button hidden for sub-Business tiers.

### Task 10: History tab (paginated list)
**Blocks:** 11,12  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/kb/versions/VersionHistoryTab.tsx`
- Create: `apps/zync-app/src/features/kb/versions/useArticleVersions.ts`
- Modify: editor right-sidebar tabs container (`apps/zync-app/src/features/kb/editor/EditorSidebar.tsx`)
**Steps:**
- [ ] Add a **History** tab to the editor right sidebar (`@zync/ui` `Tabs`).
- [ ] Implement `useArticleVersions(articleId)` — react-query page-based pagination hook calling `GET /api/kb/articles/:id/versions`, accumulating pages for `[Load more...]`.
- [ ] Render each version row: `v{n}`, relative time, author name, optional `Published` badge (derive from whether the publish produced it — show badge if the version corresponds to a publish; otherwise omit), and the label (or `—`).
- [ ] `[Load more...]` button fetches the next page (20/page); show `EmptyState` if no versions yet; `Spinner` while loading.
- [ ] i18n all labels; RTL-aware layout via `useDirection`.
**Schema / Interfaces:**
```ts
export function useArticleVersions(articleId: string): {
  versions: VersionListItem[]; loadMore: () => void;
  hasMore: boolean; isLoading: boolean;
};
export function VersionHistoryTab(props: { articleId: string }): JSX.Element;
```
**Acceptance:**
- [ ] Shows newest-first, 20 per page; `[Load more...]` appends the next page.
- [ ] Empty article shows the empty state.

### Task 11: Version diff view
**Blocks:** —  ·  **Blocked by:** 3,6,10
**Files:**
- Create: `apps/zync-app/src/features/kb/versions/VersionDiffView.tsx`
**Steps:**
- [ ] Clicking a version row in the History tab opens the diff view comparing that version to the current article (`v{n} ←→ Current (v{max})`).
- [ ] Fetch the selected version (`GET .../versions/:versionId`) for full content; compute the text diff (server can return precomputed `DiffSegment[]`, or compute client-side using the shared `computeTextDiff`); render inline diff with added (`+`) / removed (`−`) spans using token color tokens (no hardcoded colors).
- [ ] Provide a toggle for side-by-side rendered-HTML comparison using `renderVersionHtml` output (sanitized).
- [ ] Header includes a `[Restore v{n}]` button (Task 12 trigger).
- [ ] RTL-aware: diff columns mirror under `dir="rtl"`; added/removed semantics announced with `aria` for screen readers; honor `prefers-reduced-motion`.
**Schema / Interfaces:**
```ts
export function VersionDiffView(props: {
  articleId: string; versionId: string; onRestore: (v: number) => void;
}): JSX.Element;
```
**Acceptance:**
- [ ] Added and removed lines are visually and semantically distinguished.
- [ ] Side-by-side HTML toggle renders both versions' content.
- [ ] Layout correct in both LTR and RTL.

### Task 12: Restore confirmation dialog
**Blocks:** —  ·  **Blocked by:** 7,10
**Files:**
- Create: `apps/zync-app/src/features/kb/versions/RestoreVersionDialog.tsx`
**Steps:**
- [ ] `[Restore v{n}]` opens a `@zync/ui` `Dialog` with the confirmation copy ("This will replace the current article content with the content from v{n}. The current state will be saved as v{max+1} before restoring, so nothing is lost.") and `[Cancel]` / `[Restore]` buttons.
- [ ] On confirm: `POST .../versions/:versionId/restore`; on success refresh the editor content/title from the response, invalidate the version-list query, toast success.
- [ ] Accessibility: `aria-modal`, focus-trap, focus returns to the trigger on close; `prefers-reduced-motion` honored; strings i18n'd and RTL-aware.
**Schema / Interfaces:**
```ts
export function RestoreVersionDialog(props: {
  articleId: string; versionId: string; versionNumber: number;
  open: boolean; onClose: () => void; onRestored: () => void;
}): JSX.Element;
```
**Acceptance:**
- [ ] Confirming restores content and the editor reflects the restored title/content.
- [ ] A safety version is visible in History immediately after restore.
- [ ] Cancel makes no changes.

### Task 13: i18n strings + tier-gate copy
**Blocks:** —  ·  **Blocked by:** 9,10,11,12
**Files:**
- Modify: `apps/zync-app/src/features/kb/versions/i18n.ts` (or the shared `translations` catalog)
**Steps:**
- [ ] Add English + Hebrew strings for: "Version history", "Save version", "Label (optional)", "Restore version {n}?", restore confirmation body, "Load more...", "Published", relative-time formats, and the Business+ upsell message shown when a sub-Business tenant views the History tab.
- [ ] When tier < Business, the History tab renders an upsell/empty state (reuse `useUpgradeModal`/`useTierGate`) instead of the list.
**Acceptance:**
- [ ] All version-history UI strings resolve in both `en` and `he`; no hardcoded literals in components.
- [ ] Sub-Business tenants see the upsell, not version data.
