# Upsell Verification — 3-check validation + kebab modal + status column

## Context

The admin/owner dashboard already has an **Upsell Verification** table (`components/dashboard/AcceptedTicketsVerification.tsx`) built for GFIBER-648: it lists accepted upsell tickets and lets an admin classify each as Agent-Initiated / Customer-Initiated via two inline `ToggleChip`s, persisted to `upsell_tickets.initiation_type`.

The business now wants a **valid offer** to require three checks, and wants the table to surface one overall status instead of a bare classification widget:

1. **Went through the full evaluation form** — captured automatically the instant the agent clicks Accept/Decline in the Upsell Evaluator (Stage 4/Close). Not currently tracked anywhere — confirmed absent in `utils/upsell/flow.ts` and `database.types.ts`.
2. **Agent- vs Customer-Initiated** — already built (GFIBER-648, `initiation_type`). Requirement here is UI relocation: move it off the table row into a modal opened from a new kebab ("⋯") button.
3. **Objection handling** — if the customer objected, did the agent try once more to close? Not currently tracked (there's an `ObjectionDrawer` that logs *which* scripts were used into `clients.objections_handled`, but nothing about whether the agent re-attempted the close). Needs a new admin-verified field.
4. The table's **Status** column must show `pending` (still needs review), `accepted` (reviewed, all three checks pass), or `rejected` (reviewed, something failed) — there is no such column today; the closest analog is the implicit "chip selected or not."

Clarified with the user during planning (AskUserQuestion):
- Objection check is a simple two-state admin verification (no explicit "N/A — no objection" option); leaving it unreviewed = pending.
- If the automatic "full flow completed" flag is `false`, the ticket is **rejected automatically** regardless of the other two checks — this is a data-integrity backstop, not something an admin toggles.
- `rejected` is always a **computed** result of the three checks — no manual "Reject" override button.

## Design

### 1. Database — new migration `supabase/migrations/<timestamp>_add_verification_fields_to_upsell_tickets.sql`

Add two nullable-where-appropriate columns to `upsell_tickets`, following the exact comment/context style used in `20260624000000_add_initiation_type_to_upsell_tickets.sql`:

```sql
alter table public.upsell_tickets
  add column full_flow_completed boolean not null default false;

comment on column public.upsell_tickets.full_flow_completed is
  'True the instant an agent records an Accept/Decline outcome via the normal '
  'Upsell Evaluator Close stage. Automatic — never set by an admin. False here '
  'on an otherwise-accepted ticket is a data-integrity signal (state reached '
  '''accepted'' without the normal flow) and alone forces verification status '
  'to rejected.';

-- Backfill: every ticket already in a terminal outcome state necessarily went
-- through the existing accept/decline step (the only code path that sets
-- state to accepted/declined_*). Without this, every historical accepted
-- ticket would flip to "rejected" the moment this ships.
update public.upsell_tickets
  set full_flow_completed = true
  where state in ('accepted', 'declined_hard', 'declined_soft');

alter table public.upsell_tickets
  add column objection_handled_correctly boolean default null;

comment on column public.upsell_tickets.objection_handled_correctly is
  'Admin-verified: did the agent attempt to close once more after a customer '
  'objection? NULL = not yet reviewed. Only meaningful when state = ''accepted''. '
  'Write access restricted to admin/owner at the application layer '
  '(setObjectionHandled Server Action), same pattern as initiation_type.';
```

**Critical: the backfill UPDATE is not optional** — without it, shipping this migration silently reclassifies every already-accepted, already-verified ticket as `rejected`.

Apply to DEV first (`supabase link --project-ref vshcimexazocttjmyrqy && supabase db push`), confirm, then regenerate types (`supabase gen types typescript --linked > utils/supabase/database.types.ts`). PROD push happens later, same as the still-pending attendance RLS promotion — do not push to `hwxkegbdnbrzvekrqxbd` as part of this feature branch.

### 2. Persist "full flow completed" automatically — `utils/upsell/payloads.ts`

`buildTicketPayload()` (lines 74-92) already derives `state` from `currentTicketState(state)`. Add one field computed the same way, from the same source of truth (`state.outcome`):

```ts
export interface TicketPayload {
  // ...existing fields
  full_flow_completed: boolean;
}

export function buildTicketPayload(...): TicketPayload {
  return {
    // ...existing fields
    full_flow_completed: state.outcome !== null,
  };
}
```

No changes needed in `app/(protected)/upsell-evaluator/actions.ts` — `createTicket`/`updateTicket` already spread whatever `buildTicketPayload` returns into the insert/update call. This satisfies requirement #1 exactly as specified: set the instant Accept/Decline is clicked (the `StageClose.tsx` `useEffect` that fires `onPersistTicket()` on `state.outcome` change), with no admin involvement.

### 3. New Server Action — `app/(protected)/dashboard/actions.ts`

Add `setObjectionHandled`, a near-exact mirror of the existing `setInitiationType` (lines 72-107): same `requireRole(current?.profile, "admin", "owner")` gate, same "only `state === 'accepted'`" guard, same `revalidatePath("/dashboard")`.

```ts
export type ObjectionResult = { ok: true } | { error: string };

export async function setObjectionHandled(
  ticketId: string,
  value: boolean | null,
): Promise<ObjectionResult> {
  // identical shape to setInitiationType, updating objection_handled_correctly
}
```

### 4. Status derivation — new `utils/upsell/verification.ts`

Pure, unit-testable function — no DB round-trip, reused by both the table and the modal:

```ts
export type VerificationStatus = "pending" | "accepted" | "rejected";

export function deriveVerificationStatus(ticket: {
  full_flow_completed: boolean;
  initiation_type: UpsellInitiationType | null;
  objection_handled_correctly: boolean | null;
}): VerificationStatus {
  if (!ticket.full_flow_completed) return "rejected";
  if (ticket.objection_handled_correctly === false) return "rejected";
  if (ticket.initiation_type === null || ticket.objection_handled_correctly === null) return "pending";
  return "accepted";
}
```

### 5. Query update — `app/(protected)/dashboard/page.tsx` (~line 124-130)

Extend the verification table's `select` to pull the two new columns:

```ts
.select("id, created_at, plan_sold, case_id, initiation_type, full_flow_completed, objection_handled_correctly, profiles!created_by(display_name, email)")
```

### 6. Design system — extend `StatusBadge`

`packages/design-system/src/components/StatusBadge/index.tsx` currently supports `'active' | 'pending' | 'expired' | 'disabled'`. Add `'accepted' | 'rejected'`:

- `.accepted` → reuse `--fiber-green-light` / `--fiber-green-sold` (same as `.active`).
- `.rejected` → `--fiber-red-light` / `--fiber-red` (tokens already exist in `packages/design-system/src/tokens/tokens.css:41-42`, currently unused by any badge variant — no new token needed).
- Update `StatusBadge.stories.tsx` and `StatusBadge.test.tsx`/`.test.cy.tsx` to cover the two new variants (DS-first convention: every component change ships story + Jest + Cypress).

### 7. New design-system component — `TicketVerificationModal`

New directory `packages/design-system/src/components/TicketVerificationModal/`, modeled directly on `AttendanceQuickModal` (portal via `createPortal`, focus-trap on open, Escape-to-close, `role="dialog" aria-modal="true"`, Tailwind-in-component styling — no separate `.module.scss`). This is the closest existing template and is otherwise unused in app code today.

Props: `{ open, onClose, ticket: { caseId, agentName, fullFlowCompleted, initiationType, objectionHandled }, onSetInitiation: (v) => void, onSetObjection: (v) => void, savingInitiation: boolean, savingObjection: boolean }`.

Content:
- Header: agent name + case ID + a `StatusBadge` showing the ticket's current derived status.
- **Check 1 (read-only)**: "Completed full evaluation flow" ✅/⚠️ from `fullFlowCompleted` — informational, no admin control (per the Q2 clarification, it never needs a manual toggle).
- **Check 2**: two `ToggleChip`s, "Agent-Initiated" / "Customer-Initiated" — same instant-apply, click-active-to-reset behavior as today's `ClassificationCell` (moved here verbatim, not reinvented).
- **Check 3**: two `ToggleChip`s, "✅ Insisted after objection" / "❌ Did not insist" — identical tri-state mechanics to Check 2, backed by `objection_handled_correctly`. Using the same `ToggleChip` pattern (rather than the DS `Checkbox` component the user's wording literally suggested) because the tri-state "not yet reviewed / pass / fail" semantics need a "click-to-reset-to-null" affordance that a native checkbox doesn't have — `ToggleChip` already provides it and is the established pattern for `initiation_type`. Flag this choice to the user after implementation in case they specifically want a literal checkbox instead.
- Footer: Close button.

Ships with `TicketVerificationModal.stories.tsx`, `.test.tsx`, `.test.cy.tsx` (mirror `AttendanceQuickModal`'s three files) and gets exported from `packages/design-system/src/components/index.tsx`.

### 8. Table changes — `components/dashboard/AcceptedTicketsVerification.tsx`

- Extend `AcceptedTicket` type with `full_flow_completed: boolean` and `objection_handled_correctly: boolean | null`.
- Remove the inline `initiation_type` column and `ClassificationCell` (logic moves into the modal).
- Add a **Status** column: `<StatusBadge status={deriveVerificationStatus(row)} />`.
- Add an **Actions** column: existing DS `IconButton` as the kebab trigger (`icon="⋯"`, `aria-label="Verify {agentName}'s ticket"`) — reuses the component already in the DS, no new button primitive needed. Click sets local `managedTicket` state and opens `TicketVerificationModal`, same local-state pattern `UserTable.tsx` uses for its own kebab → `UserManageDrawer`.
- Wire `onSetInitiation`/`onSetObjection` to `setInitiationType`/`setObjectionHandled` via the existing `startTransition` + `router.refresh()` pattern (`handleClassify`, lines 115-125) — add a sibling `handleObjection`.

## Files touched (summary)

| File | Change |
|---|---|
| `supabase/migrations/<ts>_add_verification_fields_to_upsell_tickets.sql` | new columns + **backfill** |
| `utils/supabase/database.types.ts` | regenerated after DEV push |
| `utils/upsell/payloads.ts` | `full_flow_completed` in `buildTicketPayload` |
| `utils/upsell/verification.ts` | new — `deriveVerificationStatus` |
| `app/(protected)/dashboard/actions.ts` | new `setObjectionHandled` |
| `app/(protected)/dashboard/page.tsx` | extend verification query select |
| `packages/design-system/src/components/StatusBadge/*` | add `accepted`/`rejected` variants |
| `packages/design-system/src/components/TicketVerificationModal/*` | new component + story + tests |
| `packages/design-system/src/components/index.tsx` | export new modal |
| `components/dashboard/AcceptedTicketsVerification.tsx` | status column, kebab, modal wiring |

## Tests (TDD — write these first, red before green)

- `__tests__/utils/upsell/verification.test.ts` — new, full truth table for `deriveVerificationStatus` (all 3×pending/true/false combinations, especially `full_flow_completed=false` overriding everything else to `rejected`).
- `__tests__/dashboard-objection-verification.test.ts` — new, mirrors `__tests__/dashboard-initiation-type.test.ts` structure exactly (permission checks, accepted-only guard, reset-to-null, revalidatePath, DB error handling) for `setObjectionHandled`.
- `__tests__/accepted-tickets-verification.test.tsx` — rewrite the chip-interaction blocks: assert the Status column renders the right `StatusBadge` per fixture, assert the kebab button opens the modal, assert modal callbacks call the two actions with the right ticket id.
- `packages/design-system/src/components/StatusBadge/StatusBadge.test.tsx` + `.test.cy.tsx` — add `accepted`/`rejected` cases.
- `packages/design-system/src/components/TicketVerificationModal/TicketVerificationModal.test.tsx` + `.test.cy.tsx` — focus trap, Escape-to-close, chip toggle callbacks, read-only Check-1 rendering.
- Extend whatever test currently covers `buildTicketPayload` (check `__tests__/upsell-actions.test.ts` or an existing payloads test) to assert `full_flow_completed` is `true` only when `outcome !== null`.

## Verification (end-to-end, not just green tests)

1. `npm run lint && npm run test` — all new/changed suites green.
2. Apply migration to DEV (`supabase db push` against `vshcimexazocttjmyrqy`), regenerate types, confirm `npm run build`/`tsc` still clean.
3. `npm run dev`, log in as an admin/owner, open `/dashboard`, confirm the Upsell Verification table shows a Status pill for existing accepted tickets (should read `pending` for anything not yet classified, given the backfill only sets `full_flow_completed`).
4. Click the kebab on a row, confirm the modal opens with the 3 checks, toggle initiation type and objection chips, confirm the Status pill flips to `accepted` once both are set and objection=true, and to `rejected` when objection is set to false.
5. Reload the page (`router.refresh()` isn't enough proof) to confirm persistence survived a real fetch, not just optimistic client state.

## Git workflow

Branch `feat/upsell-verification-checks` from `dev` (dashboard features branch from/merge to `dev` per `docs/knowledge/conventions.md` and this repo's established pattern for GFIBER-648-style work), PR → `dev`.

---

# Plan 2 — Manual state override (Accepted ⇄ Rejected) + audit history

## Context

Same kebab menu / modal as Plan 1. The business wants admins/owners to be able to override `upsell_tickets.state` (the outcome an agent recorded in Stage 4/Close of the Upsell Evaluator — `accepted` or `declined_hard` in practice) after the fact, **with an immutable audit trail of every change**.

Two Explore agents mapped the blast radius and the repo's existing conventions before this was designed:

- **Every KPI/metric/table in this app re-reads `upsell_tickets.state` live on each request** — `buildDashboardMetrics` (`utils/dashboard/metrics.ts`), the `InitiationRateTile` rate calculations, the Session History view, and the XLSX export all recompute from the current `state` value with no cache or materialized rollup. A manual override propagates everywhere automatically on next load — no separate invalidation needed.
- **Cooloff/eligibility is live-derived, not read from a log.** `lookupClient()` reads only the client's single most-recent ticket and calls `computeEligibility()` on its `state`. If the ticket being reclassified is that client's latest, the client's cooloff window changes immediately on the next Upsell Evaluator lookup — a real business-logic side effect worth calling out to whoever approves this, not just a technical footnote. `eligibility_checks` itself is a write-only log (`logEligibilityCheck()`), never read back — its historical rows are frozen snapshots and will not reflect a later state edit; that's expected, matching how the table already behaves for its own purpose.
- **No RLS policy anywhere is conditioned on `state`** (confirmed directly against `supabase/migrations/20260603120000_create_clients_and_upsell_tickets.sql` — `create policy "Authenticated can update upsell_tickets"` is unconditional). Same pattern as `initiation_type`: the real access control has to live at the application layer.
- **Real gap found and confirmed in scope for this feature (per user's answer):** nothing today clears `initiation_type` when a ticket's `state` moves away from `accepted`. An admin reclassifying accepted→declined leaves the old classification sitting on the row; if the ticket is later reclassified back to `accepted`, the stale classification resurfaces unreviewed. This feature closes that gap.
- **`eligibility_checks` is the one precedent for an audit-style table** in this repo (`supabase/migrations/20260612120000_create_eligibility_checks.sql`): a sibling table, one row per event, `checked_by`/`checked_at`, **no UPDATE/DELETE RLS policies at all** (true immutability, not just convention), indexed `(client_id, checked_at desc)`. This is the template for the new history table.
- **No transaction/RPC mechanism exists anywhere in the app layer** — multi-write bookkeeping (e.g. `logEligibilityCheck`) is done as independent sequential awaited calls, accepting non-atomicity. Per the user's explicit choice below, this feature introduces the **first Postgres RPC function in this repo** to get real atomicity, since the entire point of this feature is a trustworthy audit trail.

**Decisions confirmed with the user:**

1. The override UI is a simple **Accepted ⇄ Rejected** toggle — `Rejected` maps to `declined_hard`, the only "declined" value the real flow ever produces (`declined_soft`/`no_pitch` exist in the enum but are unreachable from the current UI). Not a raw 6-value enum picker.
2. Reclassification is only allowed on tickets **already in a terminal state** (`accepted`, `declined_hard`, `declined_soft`, `no_offer`) — never on `in_progress` tickets. This isn't a way to "finish" a call an agent left open.
3. Changing `state` via this feature **resets `initiation_type` to null** (and `objection_handled_correctly` too, once Plan 1 ships that column) — forces a fresh admin review instead of carrying forward a stale classification.
4. Atomicity is handled by a **new `security definer` Postgres function**, not two sequential app-layer calls — the insert-history-row + update-state happen inside one Postgres transaction, so a failure on either side rolls back both.

## Design

### 1. New table + RPC — one migration

`supabase/migrations/<timestamp>_add_upsell_state_changes.sql`:

```sql
-- Audit trail for manual admin/owner overrides of upsell_tickets.state.
-- Modeled directly on eligibility_checks: one row per change, no UPDATE/DELETE
-- policies (true immutability), indexed for "history for this ticket, newest first".
create table public.upsell_state_changes (
  id         uuid primary key default gen_random_uuid(),
  ticket_id  uuid not null references public.upsell_tickets(id) on delete cascade,
  changed_by uuid references public.profiles(id) on delete set null,
  changed_at timestamptz not null default now(),
  old_state  public.upsell_state not null,
  new_state  public.upsell_state not null,
  reason     text
);

create index upsell_state_changes_ticket_idx
  on public.upsell_state_changes (ticket_id, changed_at desc);

alter table public.upsell_state_changes enable row level security;

-- Read: admin/owner only (this is management/audit data, unlike eligibility_checks
-- which any authenticated agent may need to see for their own client lookups).
create policy "Admins can view state change history"
  on public.upsell_state_changes for select
  to authenticated
  using (public.current_profile_role() in ('admin', 'owner'));

-- No insert/update/delete policy for any client role — all writes happen inside
-- change_upsell_ticket_state() below, which runs as a security definer and
-- bypasses RLS by design. This mirrors why eligibility_checks has no UPDATE/DELETE
-- policy at all: immutability enforced by the absence of a policy, not a check.

-- Single-transaction override: validates role + old-state + new-state, updates
-- upsell_tickets, resets the now-stale verification columns, and inserts the
-- audit row — all inside one Postgres transaction (a raised exception rolls
-- back everything, including the update).
create or replace function public.change_upsell_ticket_state(
  p_ticket_id uuid,
  p_new_state public.upsell_state,
  p_reason text default null
)
returns public.upsell_tickets
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_role text;
  v_old_state public.upsell_state;
  v_result public.upsell_tickets;
begin
  select role into v_role from public.profiles where id = auth.uid();
  if v_role is null or v_role not in ('admin', 'owner') then
    raise exception 'Only admin/owner may change ticket state';
  end if;

  if p_new_state not in ('accepted', 'declined_hard') then
    raise exception 'New state must be accepted or declined_hard';
  end if;

  select state into v_old_state
    from public.upsell_tickets
    where id = p_ticket_id
    for update; -- row lock for the duration of the transaction

  if v_old_state is null then
    raise exception 'Ticket not found';
  end if;
  if v_old_state not in ('accepted', 'declined_hard', 'declined_soft', 'no_offer') then
    raise exception 'Only tickets in a terminal state can be reclassified';
  end if;
  if v_old_state = p_new_state then
    raise exception 'Ticket is already in that state';
  end if;

  update public.upsell_tickets
    set state = p_new_state,
        initiation_type = null
        -- , objection_handled_correctly = null  -- add once Plan 1's column exists
    where id = p_ticket_id
    returning * into v_result;

  insert into public.upsell_state_changes (ticket_id, changed_by, old_state, new_state, reason)
    values (p_ticket_id, auth.uid(), v_old_state, p_new_state, p_reason);

  return v_result;
end;
$$;

revoke execute on function public.change_upsell_ticket_state(uuid, public.upsell_state, text) from public;
grant execute on function public.change_upsell_ticket_state(uuid, public.upsell_state, text) to authenticated;

comment on function public.change_upsell_ticket_state is
  'Admin/owner-only override of an upsell_tickets.state that has already reached '
  'a terminal outcome. Atomically updates the ticket, clears the now-stale '
  'initiation_type, and inserts an immutable audit row into upsell_state_changes. '
  'This repo''s first security-definer RPC used for a multi-table write — see '
  'plan doc for why sequential app-layer calls were rejected here specifically.';
```

Reuses `public.current_profile_role()`, the SECURITY DEFINER helper already introduced in `20260707130000_fix_profiles_update_policy_recursion.sql` for RLS-safe role checks — no new helper needed for the SELECT policy.

**Critical implementation detail:** this RPC must be called with the **request-scoped session client** (`utils/supabase/server.ts`'s `createClient()` — cookie-bound, carries the caller's JWT), **not** `createAdminClient()` (`utils/supabase/admin.ts`, service-role key). The function's internal `auth.uid()` check only resolves to the calling admin/owner when invoked through the user's own session; the service-role client has no user JWT context and `auth.uid()` would read as null inside the function, always failing the role check. This is a real deviation from how every other action in `dashboard/actions.ts` is written today (they all use `createAdminClient()` after an app-layer `requireRole` check) — call this out explicitly in code review since it's easy to copy-paste the wrong client.

### 2. Server Actions — `app/(protected)/dashboard/actions.ts`

```ts
export type ChangeStateResult = { ok: true; ticket: TicketRow } | { error: string };

export async function changeTicketState(
  ticketId: string,
  newState: "accepted" | "declined_hard",
  reason?: string,
): Promise<ChangeStateResult> {
  const current = await getCurrentUserWithProfile();
  try {
    requireRole(current?.profile, "admin", "owner"); // defense-in-depth; the RPC re-checks internally
  } catch (e) {
    if (e instanceof RoleError) return { error: e.message };
    throw e;
  }

  const supabase = await createClient(); // session client — NOT createAdminClient()
  const { data, error } = await supabase.rpc("change_upsell_ticket_state", {
    p_ticket_id: ticketId,
    p_new_state: newState,
    p_reason: reason ?? null,
  });

  if (error) return { error: error.message }; // surfaces the RPC's raised exceptions verbatim (terminal-state guard, etc.)

  revalidatePath("/dashboard");
  return { ok: true, ticket: data };
}

export type StateHistoryEntry = {
  id: string;
  changed_at: string;
  old_state: string;
  new_state: string;
  reason: string | null;
  changed_by_name: string | null;
};

export async function getTicketStateHistory(ticketId: string): Promise<StateHistoryEntry[] | { error: string }> {
  const current = await getCurrentUserWithProfile();
  try {
    requireRole(current?.profile, "admin", "owner");
  } catch (e) {
    if (e instanceof RoleError) return { error: e.message };
    throw e;
  }

  const admin = createAdminClient(); // plain read, service-role fine here — no RLS-sensitive write
  const { data, error } = await admin
    .from("upsell_state_changes")
    .select("id, changed_at, old_state, new_state, reason, profiles!changed_by(display_name, email)")
    .eq("ticket_id", ticketId)
    .order("changed_at", { ascending: false });

  if (error) return { error: "Could not load state history." };
  return (data ?? []).map((row) => ({
    id: row.id,
    changed_at: row.changed_at,
    old_state: row.old_state,
    new_state: row.new_state,
    reason: row.reason,
    changed_by_name: row.profiles?.display_name ?? row.profiles?.email ?? null,
  }));
}
```

### 3. UI — the kebab becomes a menu with two options; History gets its own modal

Revised per user feedback: a single modal with 4 sections (3 checks + override + history) was getting dense. Split it — the kebab (⋯) opens a small menu, not a modal directly:

**New DS component — `KebabMenu`** (`packages/design-system/src/components/KebabMenu/`): wraps the existing `IconButton` (⋯ trigger, already in the DS — no new button primitive) and, on click, portal-renders a small anchored popover (`createPortal` to `document.body`, positioned from the trigger's `getBoundingClientRect()` — same portal/positioning technique `AttendanceQuickModal` already uses, chosen specifically because the Upsell Verification table can sit inside a horizontally-scrolling container and a non-portaled absolutely-positioned menu risks clipping, the same class of overflow bug already hit twice in this codebase for the Attendance grid, GFIBER-663/671). Closes on Escape, outside-click, or item selection. `role="menu"` / each item `role="menuitem"`, arrow-key navigation between items (standard menu a11y pattern). Props: `{ ariaLabel: string; items: { label: string; onSelect: () => void }[] }`. Ships with `.stories.tsx`, `.test.tsx`, `.test.cy.tsx` per DS-first convention.

**`components/dashboard/AcceptedTicketsVerification.tsx`** Actions column renders:
```tsx
<KebabMenu
  ariaLabel={`Actions for ${agentName}'s ticket`}
  items={[
    { label: "Verify", onSelect: () => setVerifying(ticket) },
    { label: "History", onSelect: () => setViewingHistory(ticket) },
  ]}
/>
```
Two independent pieces of local state (`verifying` / `viewingHistory`), each conditionally rendering its own modal — mirrors the existing `managed` state pattern in `UserTable.tsx`, just two of them instead of one.

**`TicketVerificationModal`** (Plan 1) keeps only the original three checks **plus** the new "Override Outcome" section — no history embedded:
- **"Override Outcome"** — only rendered when `ticket.state` is terminal (per decision #2); shows the current `OUTCOME_BADGE` (reused from `utils/upsell/outcomeDisplay.ts`, already used by `SessionHistoryView`/`TicketDetailView`) plus two buttons, "Mark Accepted" / "Mark Rejected", disabled on whichever is already current. A short optional reason `TextField` (DS component, already used elsewhere e.g. `StageClose.tsx`'s Order ID field) above the buttons — encouraged, not DB-enforced (column is nullable). Wires through the existing `startTransition` + `router.refresh()` pattern already used for the other two checks in this modal.

**New DS component — `TicketStateHistoryModal`** (`packages/design-system/src/components/TicketStateHistoryModal/`), opened by the "History" menu item, modeled on the same `AttendanceQuickModal` portal/focus-trap/Escape-to-close shell as `TicketVerificationModal`:
- Header: agent name + Case ID + current `OUTCOME_BADGE`.
- Body: fetches `getTicketStateHistory(ticketId)` on open, renders a `DataTable` (same component `SessionHistoryView` already uses): `changed_at` (existing `formatDate` pattern) → `old_state → new_state` (two `OUTCOME_BADGE`s with an arrow between them) → `changed_by_name` ("who made the change" — the user's explicit ask) → `reason`. Empty state: "No manual overrides yet."
- Footer: Close button.
- Ships with `.stories.tsx`, `.test.tsx`, `.test.cy.tsx`.

### 4. Side effects to surface in the PR description (not code changes, just visibility)

- Reclassifying a client's most-recent ticket changes that client's live cooloff/eligibility on the very next Upsell Evaluator lookup — flag this to whoever reviews/QAs the PR so it's an expected, not surprising, behavior.
- Dashboard KPIs, the Customer/Agent-Initiated rate tiles, Session History, and the XLSX export all shift immediately on next load/generation — no separate cache to bust.

## Files touched (summary)

| File | Change |
|---|---|
| `supabase/migrations/<ts>_add_upsell_state_changes.sql` | new table + RLS (select-only for admin/owner, no insert/update/delete policy) + `change_upsell_ticket_state()` RPC |
| `utils/supabase/database.types.ts` | regenerated — adds `upsell_state_changes` table types + the RPC's function signature |
| `app/(protected)/dashboard/actions.ts` | new `changeTicketState`, new `getTicketStateHistory` |
| `packages/design-system/src/components/KebabMenu/*` | new component — menu trigger + story + tests |
| `packages/design-system/src/components/TicketStateHistoryModal/*` | new component — history modal + story + tests |
| `packages/design-system` `TicketVerificationModal` (from Plan 1) | add the "Override Outcome" section only (no history) |
| `packages/design-system/src/components/index.tsx` | export `KebabMenu` + `TicketStateHistoryModal` |
| `components/dashboard/AcceptedTicketsVerification.tsx` | swap the lone kebab button for `KebabMenu` (Verify / History), two modal states instead of one |

## Tests (TDD)

- `__tests__/dashboard-state-change.test.ts` — new. Mock `.rpc()` (not `.from().update()`, since this action calls the RPC, not a table method) via the same `vi.hoisted` pattern as `dashboard-initiation-type.test.ts`: role gate (unauthenticated/agent rejected before the RPC is even called), success path returns `{ ok: true, ticket }`, RPC-raised-exception messages surfaced verbatim as `{ error }` (terminal-state guard, already-in-that-state guard, not-found), `revalidatePath` called only on success.
- `__tests__/dashboard-state-history.test.ts` — new, mirrors the read-only query pattern: role gate, empty history returns `[]`, maps `profiles!changed_by` join correctly, DB error returns `{ error }`.
- `packages/design-system/src/components/KebabMenu/KebabMenu.test.tsx` + `.test.cy.tsx` — new: opens on click, closes on Escape/outside-click/item-select, `role="menu"`/`"menuitem"` present, arrow-key navigation, calls the right item's `onSelect`.
- `packages/design-system/src/components/TicketStateHistoryModal/TicketStateHistoryModal.test.tsx` + `.test.cy.tsx` — new: renders fetched rows (old→new badges, `changed_by_name`, `reason`), empty state, focus trap/Escape-to-close (mirrors `AttendanceQuickModal.test.tsx`).
- Extend the `TicketVerificationModal` test suite (Plan 1) with: Override section hidden when `state` is non-terminal, buttons disabled on the currently-active state, clicking the inactive button calls `changeTicketState` with the right args.
- Extend `__tests__/accepted-tickets-verification.test.tsx`: the Actions column renders a `KebabMenu` with exactly two items, selecting "Verify" opens the verification modal, selecting "History" opens the history modal, and the two are mutually exclusive local states (opening one doesn't leave the other mounted).
- No Vitest can exercise the Postgres function body itself (no pgTAP in this repo) — the RPC's internal guards (role check, terminal-state check, atomicity) can only be verified by hitting a real DEV database. Call this out explicitly as a manual verification step, not a gap to "solve" with more unit tests.

## Verification (end-to-end)

1. `npm run lint && npm run test`.
2. Apply migration to DEV (`supabase db push` against `vshcimexazocttjmyrqy`), regenerate types.
3. **Manually exercise the RPC against real DEV Postgres** (not just through the UI) via `supabase db execute` or the SQL editor: confirm a non-admin `auth.uid()` gets rejected, confirm an `in_progress` ticket is rejected, confirm a same-state no-op is rejected, confirm a successful call leaves exactly one new row in `upsell_state_changes` AND the ticket's `state`/`initiation_type` updated together (query both tables after the call — this is the one guarantee unit tests can't give you).
4. `npm run dev`, as admin/owner: open the kebab menu on an accepted ticket → "Verify" → override to Rejected with a reason. Confirm the Status column/badge updates on `router.refresh()`. Re-open the kebab → "History" → confirm the new row shows old→new state, your name, and the reason. Re-open "Verify" → confirm `initiation_type` classification reset (Check 2 chips both unselected).
5. Confirm the cooloff side effect explicitly: pick a client whose most-recent ticket is the one just reclassified, run a fresh Upsell Evaluator lookup for that GAID, confirm the eligibility banner reflects the new state's cooloff window.

## Git workflow

Same branch as Plan 1 if built together (`feat/upsell-verification-checks`), or a follow-on branch `feat/upsell-state-override-history` from `dev` if shipped separately — this plan composes with but does not require Plan 1's three-check columns to exist first (only the modal component needs to exist to host the new section; the RPC can ship independently and add the Override section to a plain modal if Plan 1 hasn't landed yet).
