# Contract Renewal & Amendment — Implementation Plan

**Spec:** docs/specs/2026-05-31-contract-renewal-amendment.md  ·  **Slug:** contract-renewal-amendment  ·  **Wave:** 12
**Depends on:** audit-compliance, contracts-esignature, foundation-auth-rbac

## Goal
Add two post-signature workflows on top of the e-signature contract lifecycle: **renewal** (a new contract spawned from an expiring SIGNED contract) and **amendment** (a new revision spawned from an active SIGNED contract). Both produce fresh `contracts` rows in `DRAFT` status linked back to their origin via FK, preserving the immutability and audit integrity of the original SIGNED contract (IL legal record-keeping). A daily cron warns OWNER/ADMIN of contracts expiring within 60 days that have no renewal yet.

## Architecture
This spec is a delta on the `contracts-esignature` module — it introduces **no new tables**. It adds four columns to the upstream `contracts` table (defined by `contracts-esignature`): `renewed_from_id`, `amended_from_id`, `effective_date`, `expiry_date`. Three new API routes (`renew`, `amend`, `lineage`) live in the existing contracts route file. Renew/amend handlers copy the original contract's `content` (Tiptap JSONB) and clone its `contract_signatories` rows with **freshly generated tokens** (never reuse the original token — `contract_signatories.token` is `NOT NULL UNIQUE`). Every write runs inside `db.transaction` and emits an `audit_log` row (entity_type `'contract'`, action `'created'`) via the `auditLog` Drizzle table and `tenantQuery` enforcement — this is why `audit-compliance` is a dependency. The expiry cron mirrors the established `/api/cron/*` HTTP-route + `CRON_SECRET` timing-safe-header + `wrangler.toml [triggers]` pattern (as used by `subscription-trial-check`), calling `createNotification` (from `@zync/notifications`) for in-app alerts and the existing `sendEmail` adapter gated on the `user_preferences` notification toggle. UI plugs into the existing `/contracts/:id` detail page (renewal banner, More-actions menu) and the existing `/contracts` list (lineage indentation).

Upstream tables/exports consumed: `contracts`, `contract_signatories`, `contract_audit_log`, `audit_log` / `auditLog`, `customers`, `users`, `tenants`, `notifications`, `user_preferences`, `createNotification`, `sendEmail`, `tenantQuery`, `requirePermission`, `authMiddleware`, `timingSafeEqual`, `buildPaginated`, plus the ESLint rules `require-audit-in-transaction`, `no-raw-drizzle-from-routes`, `require-zod-validation-in-routes`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — contract routes + cron route.
- **App:** `apps/zync-app` (Vite + React) — detail banner, renewal/amendment forms, lineage display, react-query hooks.
- **DB package:** `packages/db` (Drizzle) — `contracts` schema delta + migration.
- **Libraries:** Drizzle ORM, Zod (route validation), `@zync/notifications` (`createNotification`), `@zync/auth` (`requirePermission`, `timingSafeEqual`), Tiptap JSON utilities (content copy, dates substitution).
- **Cloudflare bindings:** Neon Postgres via Hyperdrive (`DB`), Cron Triggers, `CRON_SECRET` (secret), email via existing `RESEND_API_KEY`/`sendEmail`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db/src/schema/contracts.ts`, migration SQL | No (root) |
| B — server core | 2, 3, 4 | `apps/zync-api/src/routes/contracts.ts`, `apps/zync-api/src/lib/contract-lineage.ts` | 2 & 3 parallel; 4 after 1 |
| C — cron | 5 | `apps/zync-api/src/cron/contract-expiry-reminder.ts`, `index.ts`, `wrangler.toml` | After 1 |
| D — UI | 6, 7, 8 | `apps/zync-app/src/pages/contracts/*`, hooks | After 2–4; 6/7/8 parallel |
| E — verify | 9 | test/lint configs | Last |

## Tasks

### Task 1: Contracts schema delta (renewal/amendment linkage + term dates)
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/contracts.ts` (add 4 columns to the existing `contracts` table definition)
- Create: `packages/db/migrations/<timestamp>_contract_renewal_amendment.sql`
**Steps:**
- [ ] Add the four columns to the existing `contracts` Drizzle table object — do NOT redefine the table.
- [ ] `renewed_from_id` and `amended_from_id` are self-referential FKs to `contracts(id)` (UUID→UUID), nullable.
- [ ] `effective_date` and `expiry_date` are `DATE` (calendar dates, NOT `_at`/TIMESTAMPTZ) — keep them as the spec defines.
- [ ] Add a partial index on `(tenant_id, expiry_date)` filtered to `status = 'SIGNED' AND expiry_date IS NOT NULL` to back the cron query.
- [ ] Generate and check in the forward migration SQL.
**Schema / Interfaces:**
```sql
-- Schema delta on the existing `contracts` table (owned by contracts-esignature).
ALTER TABLE contracts ADD COLUMN renewed_from_id UUID REFERENCES contracts(id);
ALTER TABLE contracts ADD COLUMN amended_from_id UUID REFERENCES contracts(id);
ALTER TABLE contracts ADD COLUMN effective_date  DATE;
ALTER TABLE contracts ADD COLUMN expiry_date     DATE;

CREATE INDEX idx_contracts_expiry_signed
  ON contracts (tenant_id, expiry_date)
  WHERE status = 'SIGNED' AND expiry_date IS NOT NULL;
```
```ts
// packages/db/src/schema/contracts.ts — added to existing pgTable('contracts', { ... }):
renewedFromId: uuid('renewed_from_id').references(() => contracts.id),
amendedFromId: uuid('amended_from_id').references(() => contracts.id),
effectiveDate: date('effective_date'),
expiryDate:    date('expiry_date'),
```
**Acceptance:**
- [ ] Migration applies cleanly to a Neon branch; the four columns and the partial index exist.
- [ ] `renewed_from_id`/`amended_from_id` are UUID FKs to `contracts(id)`; `effective_date`/`expiry_date` are `DATE`.
- [ ] No existing `contracts` column is altered or dropped.

### Task 2: `POST /api/contracts/:id/renew`
**Blocks:** 6, 8  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts` (add route)
- Create: `apps/zync-api/src/lib/contract-clone.ts` (shared signatory-clone helper)
**Steps:**
- [ ] Guard with `authMiddleware` + `requirePermission('contracts:write')`.
- [ ] Validate body with Zod: `{ title: string (min 1), effectiveDate: string (ISO date), expiryDate: string (ISO date), copyContent: boolean }`; reject invalid with 422 (`require-zod-validation-in-routes`).
- [ ] Load the source contract via `tenantQuery` (`no-raw-drizzle-from-routes`); 404 if absent. Reject with 422 if its `status !== 'SIGNED'` (renewal only follows a SIGNED contract).
- [ ] In a single `db.transaction`:
  - [ ] Insert a new `contracts` row: `tenant_id`, `customer_id`, `template_id` copied from source; `title` = body.title; `content` = (copyContent ? cloneContentWithDates(source.content, effectiveDate, expiryDate) : empty Tiptap doc); `status = 'DRAFT'`; `renewed_from_id = source.id`; `effective_date`/`expiry_date` from body; `created_by = ctx.userId`.
  - [ ] Call `cloneSignatories(tx, source.id, newContract.id)` — clones each `contract_signatories` row with name/email/`order` copied but a **freshly generated UUID v4 token**, `token_expires_at` reset (e.g. now + 30 days, recomputed on send), and all signed/viewed/declined fields cleared. Never copy the original token (UNIQUE + capability leak).
  - [ ] Insert an `audit_log` row in the same transaction: `entityType='contract'`, `entityId=newContract.id`, `action='created'`, `changes={ renewedFromId: source.id }`, `actorId=ctx.userId`, `requestId`, `ip` (`require-audit-in-transaction`).
- [ ] Return 201 with the new contract JSON.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/lib/contract-clone.ts
export async function cloneSignatories(
  tx: DbTx, sourceContractId: string, targetContractId: string
): Promise<void>;
// copies name,email,order; generates fresh token (crypto.randomUUID()),
// token_expires_at = now()+30d; nulls viewed_at/signed_at/signature_data/declined_at.

export function cloneContentWithDates(
  content: unknown, effectiveDate: string, expiryDate: string
): unknown; // Tiptap JSON in/out; substitutes {{date}}/{{effective_date}}/{{expiry_date}} nodes.
```
**Acceptance:**
- [ ] Non-SIGNED source → 422; missing source → 404; missing permission → 403.
- [ ] New row has `status='DRAFT'`, `renewed_from_id=source.id`, same customer; cloned signatories carry new unique tokens (no UNIQUE violation, no token reuse).
- [ ] One `audit_log` row written in the same transaction; rolling back the insert rolls back the audit row.

### Task 3: `POST /api/contracts/:id/amend`
**Blocks:** 7, 8  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts` (add route)
**Steps:**
- [ ] Guard with `authMiddleware` + `requirePermission('contracts:write')`.
- [ ] Validate body with Zod: `{ title: string (min 1), effectiveDate: string (ISO date), content?: JSONValue }`.
- [ ] Load source via `tenantQuery`; 404 if absent; 422 if `status !== 'SIGNED'`.
- [ ] In a single `db.transaction`:
  - [ ] Insert new `contracts` row: `tenant_id`, `customer_id`, `template_id` from source; `title` = body.title; `content` = (body.content ?? source.content); `status='DRAFT'`; `amended_from_id = source.id`; `effective_date` from body; `created_by = ctx.userId`.
  - [ ] Call `cloneSignatories(tx, source.id, newContract.id)` (fresh tokens, as Task 2).
  - [ ] Insert `audit_log` row: `entityType='contract'`, `entityId=newContract.id`, `action='created'`, `changes={ amendedFromId: source.id }` (`require-audit-in-transaction`).
- [ ] Validate `content` node types against `ALLOWED_NODE_TYPES` (same Tiptap content-security set as `contracts-esignature`); reject unknown node with 422.
- [ ] Return 201 with the new contract JSON.
**Acceptance:**
- [ ] Non-SIGNED source → 422; new row has `amended_from_id=source.id`, `status='DRAFT'`.
- [ ] Cloned signatories carry fresh unique tokens.
- [ ] Disallowed Tiptap node in `content` → 422; one audit row per successful amend.

### Task 4: `GET /api/contracts/:id/lineage`
**Blocks:** 6, 8  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts` (add route)
- Create: `apps/zync-api/src/lib/contract-lineage.ts`
**Steps:**
- [ ] Guard with `authMiddleware` + `requirePermission('contracts:read')`.
- [ ] Resolve the chain **root**: walk `renewed_from_id`/`amended_from_id` back from `:id` to the contract with both link columns null (the original).
- [ ] Collect the root + every contract whose `renewed_from_id` OR `amended_from_id` resolves (transitively) into the chain, scoped by `tenant_id` via `tenantQuery`.
- [ ] Return `{ original, contracts: [...] }` where each entry includes `id, title, status, effectiveDate, expiryDate, renewedFromId, amendedFromId`, sorted by `effective_date` (nulls last).
- [ ] 404 if `:id` not found in tenant.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/lib/contract-lineage.ts
export interface ContractLineageNode {
  id: string; title: string; status: string;
  effectiveDate: string | null; expiryDate: string | null;
  renewedFromId: string | null; amendedFromId: string | null;
}
export interface ContractLineage {
  original: ContractLineageNode;
  contracts: ContractLineageNode[]; // includes original, sorted by effectiveDate
}
export async function getContractLineage(
  db: Db, tenantId: string, contractId: string
): Promise<ContractLineage | null>;
```
**Acceptance:**
- [ ] Given an original + 1 amendment + 1 renewal, the endpoint returns all three with correct link columns, sorted by `effective_date`.
- [ ] Cross-tenant `:id` → 404 (tenant scoping enforced).

### Task 5: Cron `contract-expiry-reminder`
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/cron/contract-expiry-reminder.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `POST /api/cron/contract-expiry-reminder`)
- Modify: `apps/zync-api/wrangler.toml` (daily 08:00 UTC trigger)
**Steps:**
- [ ] Mount `POST /api/cron/contract-expiry-reminder`; guard by comparing a `CRON_SECRET` request header with `timingSafeEqual`; reject mismatches with 401.
- [ ] Run the spec query: SIGNED contracts with `expiry_date` between `now()` and `now() + interval '60 days'` that are NOT already referenced by any row's `renewed_from_id` (i.e. not yet renewed).
- [ ] For each matching contract, resolve the tenant's OWNER/ADMIN users (role lookup) and for each: call `createNotification({ tenantId, userId, type:'contract_expiring', title, body, link:'/contracts/:id', metadata:{ contractId, expiryDate } })`.
- [ ] If the user's `user_preferences` notification toggle for email is enabled, also `sendEmail` an expiry reminder (subject `[{tenant_name}] Contract expiring: {title}`).
- [ ] Make the run idempotent for the day (skip if an unread `contract_expiring` notification for that `contractId` already exists for the user).
- [ ] Add `wrangler.toml` cron trigger `crons = ["0 8 * * *"]` for this worker.
**Schema / Interfaces:**
```sql
-- Cron selection query (verbatim from spec):
SELECT id, title, customer_id, expiry_date
FROM contracts
WHERE status = 'SIGNED'
  AND expiry_date IS NOT NULL
  AND expiry_date BETWEEN now() AND now() + interval '60 days'
  AND id NOT IN (
    SELECT renewed_from_id FROM contracts
    WHERE renewed_from_id IS NOT NULL
  );
```
```ts
export async function runContractExpiryReminder(env: Env): Promise<{ notified: number }>;
```
**Acceptance:**
- [ ] Missing/incorrect `CRON_SECRET` → 401 (timing-safe compare).
- [ ] A SIGNED contract expiring in 23 days with no renewal produces in-app notifications to OWNER+ADMIN; a contract already renewed (its id appears in some `renewed_from_id`) produces none.
- [ ] Email sent only when the recipient's `user_preferences` toggle is on; re-running same day does not duplicate notifications.

### Task 6: Renewal banner + form (contract detail)
**Blocks:** —  ·  **Blocked by:** 2, 4
**Files:**
- Modify: `apps/zync-app/src/pages/contracts/[id].tsx` (detail page — add banner + More-actions item)
- Create: `apps/zync-app/src/pages/contracts/components/RenewalForm.tsx`
- Create: `apps/zync-app/src/hooks/use-contract-renew.ts`
**Steps:**
- [ ] On a SIGNED contract whose `expiry_date` is set and within 60 days, render a renewal banner: warning icon, `aria-live="polite"`, "Expires in N days (YYYY-MM-DD)" and a `[Renew contract]` button. Respect `prefers-reduced-motion` (no animated entry).
- [ ] Add "Renew contract" to the `[⋯ More actions]` menu (always available on SIGNED, not just within 60 days).
- [ ] `RenewalForm`: fields New contract title (default `"{title} {nextYear}"`), New effective date, New expiry date, and a radio group "Copy content from original" / "Use blank template" (`role="radiogroup"`). Default = copy content.
- [ ] `useContractRenew()` posts to `POST /api/contracts/:id/renew`; on success navigate to the new DRAFT contract's edit page and toast success.
- [ ] All inputs labelled (`FormLabel`), date inputs accept Hebrew locale formatting; form is keyboard-navigable and RTL-aware (`useDirection`).
**Acceptance:**
- [ ] Banner appears only for SIGNED + `expiry_date` within 60 days; shows correct days-remaining.
- [ ] Submitting the form creates a renewal DRAFT and redirects to its editor.
- [ ] Banner has `aria-live`; radio group is keyboard-operable; layout mirrors under RTL.

### Task 7: Amendment form + More-actions wiring (contract detail)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-app/src/pages/contracts/[id].tsx` (add "Create amendment" menu item)
- Create: `apps/zync-app/src/pages/contracts/components/AmendmentForm.tsx`
- Create: `apps/zync-app/src/hooks/use-contract-amend.ts`
**Steps:**
- [ ] Add "Create amendment" to the SIGNED contract `[⋯ More actions]` menu (alongside Download PDF, Create invoice, Renew contract).
- [ ] `AmendmentForm`: Amendment title (placeholder "Amendment #1 — Rate Change"), Effective date, and a rich editor pre-filled with the original `content` (fully editable) — reuse `@zync/ui` rich-editor with `role="textbox"`, `aria-multiline`, toolbar `role="toolbar"`, RTL direction extension per locale.
- [ ] Pipe editor preview output through DOMPurify before any `dangerouslySetInnerHTML` (content-security pattern from `contracts-esignature`).
- [ ] `useContractAmend()` posts to `POST /api/contracts/:id/amend`; on success navigate to the new DRAFT editor + toast.
**Acceptance:**
- [ ] Menu item present only on SIGNED contracts; form pre-fills original content and submits an amendment DRAFT.
- [ ] Editor exposes correct ARIA roles and RTL direction; preview is DOMPurify-sanitized.

### Task 8: Lineage display (list + detail)
**Blocks:** —  ·  **Blocked by:** 2, 3, 4
**Files:**
- Modify: `apps/zync-app/src/pages/contracts/index.tsx` (list — indented lineage grouping)
- Modify: `apps/zync-app/src/pages/contracts/[id].tsx` (detail — lineage panel)
- Create: `apps/zync-app/src/hooks/use-contract-lineage.ts`
**Steps:**
- [ ] `useContractLineage(id)` calls `GET /api/contracts/:id/lineage`.
- [ ] Contract list: group contracts of the same customer; sort by `effective_date`; render renewals/amendments indented under their parent (original), each row showing title, status badge, and term/date range. Indentation must be conveyed non-visually (e.g. `aria-level` on a treegrid, or nested list semantics) — not by whitespace alone.
- [ ] Contract detail: lineage panel listing original + amendments + renewals with links and status badges.
- [ ] Status badges reuse `Badge`; date ranges use the Hebrew/locale date formatter; layout RTL-aware.
**Acceptance:**
- [ ] List indents amendments/renewals under the originating contract, sorted by `effective_date`; indentation is exposed to assistive tech.
- [ ] Detail lineage panel renders the full chain returned by the API with working navigation links.

### Task 9: Verification (lint, types, integration)
**Blocks:** —  ·  **Blocked by:** 2, 3, 4, 5, 6, 7, 8
**Files:**
- Modify: existing test/lint config only (no new tooling)
**Steps:**
- [ ] Run typecheck + ESLint across `apps/zync-api` and `apps/zync-app`; confirm `require-audit-in-transaction`, `no-raw-drizzle-from-routes`, and `require-zod-validation-in-routes` pass on the new routes.
- [ ] Integration: create renewal then amendment from a SIGNED contract; assert link columns, DRAFT status, fresh signatory tokens, and one audit row per write.
- [ ] Invoke the cron route with a valid `CRON_SECRET` against seeded data; assert notifications fire only for non-renewed, within-60-day SIGNED contracts.
**Acceptance:**
- [ ] Typecheck + lint clean; all three ESLint rules satisfied on new code.
- [ ] Renew→amend integration assertions pass; cron emits the expected notification set and is idempotent same-day.
