# Time Entry Approval Workflow — Implementation Plan

**Spec:** docs/specs/2026-05-31-time-approval-workflow.md  ·  **Slug:** time-approval-workflow  ·  **Wave:** 10
**Depends on:** contractor-payouts, foundation-auth-rbac, notification-center, projects-module, settings-module, time-entry-locking

## Goal
Add a manager-approval gate for CONTRACTOR-role time entries. When the tenant enables `tenant_settings.contractor_require_time_approval`, contractor entries are created in `pending` state and a manager must approve or reject them before they are eligible for payout. Non-contractor entries are always `auto_approved`. Includes a manager approval queue (`/time/approvals`), contractor-facing status badges/resubmit on `/time`, a Business+ payroll CSV export that locks exported entries, rejection notifications, and a daily approver digest cron.

## Architecture
This feature extends the existing `time_entries` table (owned by `time-management`) with four approval columns (`approval_note`, `approved_by`, `approved_at`, `submitted_at`) and a partial pending index. The `approval_status` column itself is a base column owned by `time-management` (wave 5) — this plan owns the approval *workflow* (states, transitions, queue, digest), not the column DDL. It does NOT create new tables. The approval gate is enforced inside the `createTimeEntry` mutation: a contractor entry (entry has `contractor_id` set, or creating user has CONTRACTOR role) plus an enabled `contractor_require_time_approval` flag yields `approval_status = 'pending'`; everything else yields `'auto_approved'`.

The `contractor_require_time_approval` flag lives on the `tenant_settings` table and is owned by `time-management` (wave 5, the earliest reader is contractor-portal at wave 8). This plan reads it via `tenantQuery` directly (defaulting to `true` if the row is absent) — never the AI-config `getTenantSettings` accessor (that operates on `ai_tenant_settings`), and never `contractor-settings`' wave-11 `getContractorSettings` (a backward dependency, since `contractor-settings` depends on this plan). `contractor-settings` (spec 148, wave 11) is the editing UI and only consumes/writes this flag. The `approval_status`, `locked_at`, and `locked_reason` columns are likewise base columns owned by `time-management` (wave 5) — this plan does NOT emit their `ALTER TABLE`; it reads/writes `approval_status` for the workflow and reads `locked_at`. The `/api/time/:id/unlock` + `/api/time/lock-period` endpoints and the lock-consistency constraint are owned by `time-entry-locking`.

Data flow: contractor logs time → `createTimeEntry` sets `pending` → manager opens `/time/approvals` (server route `GET /api/time/approvals` filtered to pending) → approve (`POST /api/time/:id/approve`) or reject (`POST /api/time/:id/reject`) singly or in bulk (`POST /api/time/approvals/bulk`) → on reject, `createNotification` fires to the contractor → contractor resubmits (`POST /api/time/:id/resubmit`). Payout: `contractor-payouts` bill generation reads `approval_status IN ('approved','auto_approved')` and sets exported/billed entries to `locked`. Payroll CSV export (`GET /api/time/export/payroll`, Business+) streams approved entries and locks them (`locked_at = now()`, `locked_reason = 'approved'`, `approval_status = 'locked'`). Voiding a payout bill reopens locked entries back to `approved` via the locking spec's `/unlock` path.

Upstream consumed: `time_entries`, `contractors`, `contractor_assignments`, `payout_bills`, `tenant_settings` (reads the `contractor_require_time_approval` flag owned by `time-management`), `notifications`, `tenant_memberships`, `users`, `projects`, `tasks`; exports `authMiddleware`, `requirePermission`, `tenantQuery`, `buildPaginated`, `createNotification`, `serializeTask`. The gate flag is read via `tenantQuery` directly — NOT the AI-config `getTenantSettings`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): approval routes in `src/routes/time-approvals.ts`, mutation guard in time service, payroll CSV streamed from the Worker.
- **apps/zync-app** (Vite + React): `/time/approvals` manager queue page, status badges + resubmit on existing `/time` list, Tanstack Query hooks.
- **packages/db** (Drizzle): `time_entries` schema delta + migration.
- **packages/types**: `ApprovalStatus`, `TimeApprovalEntry`, request/response DTOs.
- Cloudflare bindings: Hyperdrive (Neon Postgres `DB`), Cron Triggers (`time-approval-digest` at 09:00), `@zync/notifications` for `createNotification`.
- Permission: `time:approve` flag stored in `tenant_memberships.permissions JSONB`, granted via `/settings/users`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Schema & types | 1, 2 | packages/db schema + migration, packages/types | Task 2 after Task 1 |
| B — Server logic | 3, 4, 5, 6 | zync-api time service, approval routes, payroll export, digest cron | 4/5/6 parallel after 3 |
| C — Client UI | 7, 8, 9 | zync-app approvals page, /time badges + resubmit, query hooks | 9 first, then 7/8 parallel |
| D — Permission wiring | 10 | settings/users permission grant | after 3 |

## Tasks

### Task 1: `time_entries` approval schema delta + migration
**Blocks:** 2, 3 · **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/time.ts`
- Create: `packages/db/migrations/<timestamp>_time_entry_approval.sql`
**Steps:**
- [ ] Add the four workflow columns (`approval_note`, `approved_by`, `approved_at`, `submitted_at`) and the partial pending index to the `time_entries` Drizzle table definition (text note, uuid approved_by FK to users, two timestamptz). `approval_status` is a base column owned by `time-management` — already on the Drizzle definition; do NOT redeclare it.
- [ ] Write the raw SQL migration mirroring the DDL below. `approval_status` (and `locked_at`/`locked_reason`) are base columns owned by `time-management` (wave 5) and already present; do NOT re-add them.
- [ ] Confirm the `approved_by` FK is `UUID REFERENCES users(id)` (UUID→UUID).
- [ ] Do NOT add the `tenant_settings.contractor_require_time_approval` flag here — it is owned by `time-management` (wave 5) and already present; read it via `tenantQuery`. `contractor-settings` (spec 148, wave 11) is the editing UI.
**Schema / Interfaces:**
```sql
-- approval_status (5-state CHECK, DEFAULT 'auto_approved') is a BASE column owned by
-- time-management (wave 5) and already exists; this plan does NOT re-add it. It adds only the
-- four workflow columns below plus the partial pending index.
ALTER TABLE time_entries ADD COLUMN approval_note TEXT;            -- rejection reason, max 500 chars enforced in API
ALTER TABLE time_entries ADD COLUMN approved_by UUID REFERENCES users(id);
ALTER TABLE time_entries ADD COLUMN approved_at TIMESTAMPTZ;
ALTER TABLE time_entries ADD COLUMN submitted_at TIMESTAMPTZ;     -- when contractor submitted for review

-- contractor_require_time_approval gate flag is owned by time-management (wave 5); read it via
-- tenantQuery — do NOT add it here.

CREATE INDEX idx_te_approval ON time_entries (tenant_id, approval_status)
  WHERE approval_status = 'pending';
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon; `approval_status` column present with the 5-value CHECK and default `'auto_approved'`.
- [ ] Existing rows read back `approval_status = 'auto_approved'`.
- [ ] Partial index `idx_te_approval` exists and is restricted to `approval_status = 'pending'`.

### Task 2: Approval types & DTOs
**Blocks:** 4, 7, 8, 9 · **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/time.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Export the `ApprovalStatus` union and the approval-bearing entry/DTO types below.
- [ ] Re-export from the package barrel so `@zync/types` exposes them.
**Schema / Interfaces:**
```typescript
export type ApprovalStatus =
  | 'auto_approved' | 'pending' | 'approved' | 'rejected' | 'locked';

export interface TimeApprovalEntry {
  id: string;                 // UUID
  tenantId: string;
  contractorName: string;     // resolved from contractors.name or users
  contractorId: string | null;
  userId: string | null;
  date: string;               // YYYY-MM-DD (started_at date, tenant tz)
  projectId: string;
  projectName: string;
  taskId: string | null;
  taskTitle: string | null;
  durationSeconds: number;
  description: string | null;
  approvalStatus: ApprovalStatus;
  approvalNote: string | null;
  submittedAt: string | null;
  approvedAt: string | null;
  approvedBy: string | null;
  lockedAt: string | null;
}

export interface ApprovalBulkRequest {
  action: 'approve' | 'reject';
  entryIds: string[];         // max 200 per call
  note?: string;              // required when action='reject', max 500 chars
}

export interface RejectRequest { note: string; }      // required, max 500 chars
export interface ApproveRequest { note?: string; }
```
**Acceptance:**
- [ ] `import { ApprovalStatus, TimeApprovalEntry } from '@zync/types'` resolves.
- [ ] `tsc` passes for the types package.

### Task 3: Approval gate in `createTimeEntry` mutation
**Blocks:** 4, 5, 10 · **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/services/time.ts` (the time-entry create/service module)
- Modify: `apps/zync-api/src/routes/time.ts` (edit-guard for pending entries)
**Steps:**
- [ ] In `createTimeEntry`, after building the insert payload, compute `approval_status`: read the gate flag `contractor_require_time_approval` from the tenant's `tenant_settings` row via `tenantQuery` directly (the column is owned by `time-management`, wave 5; default `true` if the row is absent) — NOT the AI-config `getTenantSettings` and NOT `contractor-settings`' `getContractorSettings`. The entry is a contractor entry if `contractor_id` is set OR the creating user's role is `CONTRACTOR`.
- [ ] If `(isContractorEntry && requireApproval === true)` → set `approval_status = 'pending'` and `submitted_at = now()`. Else → `approval_status = 'auto_approved'`, leave `submitted_at` null. This is enforced in the mutation, not configurable per-user.
- [ ] In the time-entry update/edit handler (`PATCH /api/time/:id`), reject edits when `approval_status = 'pending'` with `409 { error: 'pending_approval' }` (contractor must withdraw/resubmit first); reject edits when `approval_status = 'locked'` or `locked_at IS NOT NULL` with `409 { error: 'entry_locked' }` (defer to time-entry-locking's existing guard if already present — do not duplicate).
- [ ] Scope all reads/writes through `tenantQuery` (no raw Drizzle from routes).
**Schema / Interfaces:**
```typescript
// inside createTimeEntry, after payload assembly:
// Read the OWNED gate flag directly via tenantQuery (default true if the row is absent).
const row = await tenantQuery(db, tenantId)
  .select({ require: tenantSettings.contractorRequireTimeApproval })
  .from(tenantSettings)
  .limit(1);
const requireApproval = row[0]?.require ?? true;
const isContractorEntry = payload.contractorId != null || creatingUserRole === 'CONTRACTOR';
if (isContractorEntry && requireApproval === true) {
  payload.approval_status = 'pending';
  payload.submitted_at = new Date();
} else {
  payload.approval_status = 'auto_approved';
}
```
**Acceptance:**
- [ ] With flag ON, a CONTRACTOR-created entry persists `approval_status = 'pending'` and a non-null `submitted_at`.
- [ ] With flag OFF, the same entry persists `auto_approved`.
- [ ] OWNER/ADMIN/MEMBER entries always persist `auto_approved` regardless of flag.
- [ ] `PATCH /api/time/:id` on a `pending` entry returns `409 pending_approval`.

### Task 4: Approval queue + single approve/reject/resubmit routes
**Blocks:** 7, 9 · **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/time-approvals.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/time/approvals` — list entries, default `status=pending`. Query params: `contractor_id`, `project_id`, `from`, `to`, `status` (`pending`|`all`), `cursor`, `limit`. Join `contractors`/`users`, `projects`, `tasks` to populate `TimeApprovalEntry`. Page via `buildPaginated`. Authz: OWNER/ADMIN or member with `time:approve` (use `requirePermission('time:approve')`).
- [ ] `POST /api/time/:id/approve` — body optional `{ note }`. Set `approval_status='approved'`, `approved_by=userId`, `approved_at=now()`. Reject with `409 entry_locked` if `locked_at IS NOT NULL` or status `locked`. Authz: `time:approve`.
- [ ] `POST /api/time/:id/reject` — body required `{ note }` (Zod: 1–500 chars). Set `approval_status='rejected'`, `approval_note=note`. Fire `createNotification` to the entry's contractor user (Task 6 helper). `409 entry_locked` if locked. Authz: `time:approve`.
- [ ] `POST /api/time/:id/resubmit` — contractor-only on OWN rejected entry. Require current `approval_status='rejected'`; set `approval_status='pending'`, clear `approval_note`, set `submitted_at=now()`. Notify approvers is digest-only (no per-resubmit notification — Task 6 covers digest). `409` if not in `rejected` state or not owner.
- [ ] All routes use `authMiddleware`, `requirePermission`, Zod body validation, and `tenantQuery`; never raw Drizzle from the route.
**Schema / Interfaces:**
```typescript
// route table (all under authMiddleware)
GET  /api/time/approvals     // ?contractor_id&project_id&from&to&status&cursor&limit ; requires time:approve
POST /api/time/:id/approve   // { note?: string }            ; requires time:approve ; 409 entry_locked
POST /api/time/:id/reject    // { note: string (1..500) }    ; requires time:approve ; notifies contractor
POST /api/time/:id/resubmit  // {}                           ; contractor owner only ; rejected -> pending

const rejectSchema = z.object({ note: z.string().min(1).max(500) });
const approveSchema = z.object({ note: z.string().max(500).optional() });
```
**Acceptance:**
- [ ] `GET /api/time/approvals` returns only `pending` by default, paginated, with contractor/project/task names resolved.
- [ ] A MEMBER without `time:approve` gets `403`; OWNER/ADMIN and `time:approve` members get `200`.
- [ ] `approve` flips status to `approved` and stamps `approved_by`/`approved_at`.
- [ ] `reject` requires a 1–500 char note, stores it, and creates a notification for the contractor.
- [ ] `resubmit` on a `rejected` own entry returns `pending` with `approval_note` cleared; `409` on any other state.
- [ ] All four endpoints return `409` against a `locked` entry.

### Task 5: Bulk approve/reject + Business+ payroll CSV export
**Blocks:** 7 · **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/time-approvals.ts`
**Steps:**
- [ ] `POST /api/time/approvals/bulk` — body `{ action, entryIds[], note? }`. `action='reject'` requires `note` (1–500); applies the single reason to all selected. Cap `entryIds` at 200. Skip/`409`-report any locked entries; approve/reject the rest in one `tenantQuery` transaction. On reject, fire one notification per affected contractor (Task 6). Authz: `time:approve`.
- [ ] `GET /api/time/export/payroll` — query `from`, `to` (required, YYYY-MM-DD), `userId?`, `projectId?`. Authz: OWNER or ADMIN only AND tenant tier Business+ (`requireTier('business')` or equivalent gate); else `402`/`403` per gate convention.
- [ ] Select `approval_status='approved'` entries in `[from, to]` matching optional filters. Resolve person name/email (users or contractors), project, task, hours (`duration_seconds/3600`, 2 dp), rate (`COALESCE(contractor_assignments.rate_override, contractors.hourly_rate)`), amount (hours*rate, 2 dp), source, entry id.
- [ ] Stream CSV from the Worker with header row exactly: `date,person_name,person_email,project,task,hours,rate,amount,source,entry_id`. Set `Content-Disposition: attachment; filename="payroll-{period}.csv"`.
- [ ] After successfully composing the export, lock every exported entry in the same transaction: `approval_status='locked'`, `locked_at=now()`, `locked_reason='approved'` (the `time-entry-locking` enum value). This prevents double-export.
**Schema / Interfaces:**
```typescript
POST /api/time/approvals/bulk   // { action:'approve'|'reject', entryIds:string[]<=200, note?:string<=500 }
                                //   requires time:approve ; reject requires note

GET  /api/time/export/payroll   // ?from=YYYY-MM-DD&to=YYYY-MM-DD&userId=&projectId=
                                //   requires OWNER|ADMIN + Business+ ; streams CSV ; locks exported entries

// CSV header (verbatim):
// date,person_name,person_email,project,task,hours,rate,amount,source,entry_id

// lock-on-export (single transaction, after CSV assembled):
// UPDATE time_entries
//   SET approval_status='locked', locked_at=now(), locked_reason='approved'
//   WHERE id = ANY($exportedIds) AND approval_status='approved';
```
**Acceptance:**
- [ ] Bulk approve flips all selected non-locked entries to `approved` in one transaction.
- [ ] Bulk reject without a note returns `400`; with a note, all selected become `rejected` with that note and contractors are notified.
- [ ] Bulk against locked entries does not mutate them and reports them as conflicts.
- [ ] Payroll export returns CSV with the exact header, `Content-Disposition` attachment filename, and rates resolved via `rate_override` fallback to `hourly_rate`.
- [ ] Exported entries are afterward `approval_status='locked'`, `locked_at` set, `locked_reason='approved'`; a second export of the same period yields no rows.
- [ ] Non-OWNER/ADMIN or non-Business+ caller is rejected by the tier/role gate.

### Task 6: Rejection notification helper + daily approver digest cron
**Blocks:** 4, 5 · **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/services/time-approval-notify.ts` (create)
- Modify: `apps/zync-api/src/routes/cron.ts` (add `time-approval-digest` handler)
- Modify: `apps/zync-api/wrangler.toml` (register cron `time-approval-digest`)
**Steps:**
- [ ] Implement `notifyRejection(db, entry)` using `createNotification` from `@zync/notifications`: `type='time_entry_rejected'`, `title_key='notifications.time_rejected.title'`, `body_key='notifications.time_rejected.body'`, `params={ project, date, note }`, `entity_type='time_entry'`, `entity_id=entry.id`, target `user_id` = the contractor's user. The notification layer fans out to email (if user pref enabled) and Telegram (if connected) — no extra wiring here.
- [ ] Implement the `time-approval-digest` cron (CRON_SECRET protected): for each tenant with `contractor_require_time_approval=true`, count entries `approval_status='pending'`; if `>0`, send one in-app digest notification (`type='time_approval_digest'`) at 09:00 tenant-local time to each approver (OWNER/ADMIN + members with `time:approve`). No per-entry notifications — digest only.
- [ ] Compute the nav badge count via `GET /api/time/approvals?status=pending&limit=1` (total count) — surfaced in Task 7; no new endpoint needed.
**Schema / Interfaces:**
```typescript
export async function notifyRejection(db: Db, entry: TimeEntryRow): Promise<void>;
// createNotification({ tenantId, userId, type:'time_entry_rejected',
//   titleKey:'notifications.time_rejected.title', bodyKey:'notifications.time_rejected.body',
//   params:{ project, date, note }, entityType:'time_entry', entityId:entry.id });

// wrangler.toml cron:  "0 * * * *"  handler keyed 'time-approval-digest' (per-tenant 09:00 local gate inside)
```
**Acceptance:**
- [ ] Rejecting an entry creates exactly one `notifications` row for the contractor with `type='time_entry_rejected'` and the rejection note in `params`.
- [ ] The digest cron creates at most one digest notification per approver per day per tenant, only when pending count > 0.
- [ ] No per-entry "awaiting review" notification is created.

### Task 7: `/time/approvals` manager queue page
**Blocks:** — · **Blocked by:** 2, 4, 5, 9
**Files:**
- Create: `apps/zync-app/src/pages/time/TimeApprovalsPage.tsx`
- Create: `apps/zync-app/src/pages/time/RejectModal.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/time/approvals`, guarded by `time:approve`/OWNER/ADMIN)
- Modify: app nav (add "Time Approvals" item with pending count badge)
**Steps:**
- [ ] Header "Time Approvals" with a count badge of pending entries. Filters: Contractor dropdown, Project dropdown, Date range (from/to), Status (Pending default / All).
- [ ] Table columns: Contractor | Date | Project | Task | Duration | Description | Actions. Per-row: Approve (green check, immediate via `useApproveEntry`) and Reject (red X, opens `RejectModal`).
- [ ] `RejectModal`: required textarea "Reason for rejection" (max 500 chars), Cancel / Reject; on confirm calls reject mutation.
- [ ] Bulk: checkbox column with select-all; sticky bulk toolbar appears when ≥1 selected: "Approve selected (N)" / "Reject selected (N)" (bulk reject uses one shared reason).
- [ ] Approved and Locked tabs (read-only) for audit.
- [ ] Business+ only: `[Export ▾]` → "Export payroll CSV" calling `GET /api/time/export/payroll` with the current period filter; hide for non-Business+.
- [ ] A11y: action buttons have `aria-label` (icon-only), table uses proper `<th scope>`; modal is a focus-trapped `Dialog` with labelled textarea; honor `prefers-reduced-motion` on toolbar/modal transitions; RTL-safe layout (logical properties, no hardcoded left/right).
**Acceptance:**
- [ ] Page lists pending entries, filters work, and an OWNER/ADMIN/`time:approve` member can reach it; others are routed away.
- [ ] Approve is one-click; Reject requires a reason; bulk approve/reject operate on the selection.
- [ ] Approved/Locked tabs render read-only.
- [ ] Export option visible only on Business+ and downloads the payroll CSV.
- [ ] Keyboard navigation and screen-reader labels verified; layout mirrors correctly in Hebrew/RTL.

### Task 8: `/time` contractor status badges + resubmit + edit-lock
**Blocks:** — · **Blocked by:** 2, 9
**Files:**
- Modify: `apps/zync-app/src/pages/time/TimeListPage.tsx` (existing list)
- Create: `apps/zync-app/src/pages/time/ApprovalStatusBadge.tsx`
**Steps:**
- [ ] Render `ApprovalStatusBadge` per row: `auto_approved` → no badge; `pending` → "Awaiting approval" (amber, clock); `approved` → "Approved" (green, check); `rejected` → "Rejected" (red, X) with tooltip showing `approvalNote`; `locked` → "Locked" (grey, lock) tooltip "Included in payout". Use design-system tokens (no hardcoded colors).
- [ ] Rejected rows show an inline "Resubmit" button → opens the edit form pre-filled; on save calls `POST /api/time/:id/resubmit` then the normal edit save, flipping back to `pending`.
- [ ] Disable the edit button for `pending` entries with tooltip "Cannot edit while awaiting approval — withdraw first". Disable edit for `locked` entries.
- [ ] A11y: badge color is not the sole signal — include text + icon; tooltips reachable by keyboard; `prefers-reduced-motion` respected; RTL-safe.
**Acceptance:**
- [ ] Each status renders the correct badge text/icon/color; `auto_approved` shows nothing.
- [ ] Rejected rows expose Resubmit; resubmitting returns the entry to `pending` and clears the note.
- [ ] Edit is disabled (with the specified tooltip) for `pending` and `locked` entries.

### Task 9: Tanstack Query hooks for approvals
**Blocks:** 7, 8 · **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/pages/time/useTimeApprovals.ts`
**Steps:**
- [ ] `useTimeApprovals(filters)` → `GET /api/time/approvals`, returns `TimeApprovalEntry[]` + pagination + pending count.
- [ ] `useApproveEntry()`, `useRejectEntry()`, `useResubmitEntry()`, `useBulkApproval()` mutations; on success invalidate `['time','approvals']` and the `/time` list query so badges refresh.
- [ ] `usePayrollExport()` → triggers the CSV download (fetch + blob, attachment filename from response header).
**Acceptance:**
- [ ] Mutations optimistically/eventually update both the approvals queue and the contractor `/time` list.
- [ ] Pending count is derivable for the nav badge.

### Task 10: `time:approve` permission grant in `/settings/users`
**Blocks:** — · **Blocked by:** 3
**Files:**
- Modify: `apps/zync-app/src/pages/settings/UserDetailPermissions.tsx` (member detail → Permissions tab)
- Modify: `apps/zync-api/src/routes/settings-users.ts` (persist permission flag)
**Steps:**
- [ ] Add a `time:approve` toggle on the member Permissions tab, visible/editable by OWNER/ADMIN only, for MEMBER-role users.
- [ ] Persist the flag into `tenant_memberships.permissions JSONB` (e.g. `{ "time:approve": true }`); server reads it in `requirePermission('time:approve')`.
- [ ] Ensure OWNER/ADMIN always pass `time:approve` checks implicitly (role-based), independent of the JSONB flag.
**Schema / Interfaces:**
```typescript
// tenant_memberships.permissions JSONB shape (additive):
// { "time:approve": boolean }  — additive key alongside any existing per-member flags
// requirePermission('time:approve') passes if role in (OWNER,ADMIN) OR permissions['time:approve']===true
```
**Acceptance:**
- [ ] OWNER/ADMIN can grant `time:approve` to a MEMBER; the flag persists in `tenant_memberships.permissions`.
- [ ] A granted MEMBER can access `/time/approvals` and the approval endpoints; revoking removes access.
