diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
new file mode 100644
index 0000000..229c1be
--- /dev/null
+++ b/.planning/PROJECT.md
@@ -0,0 +1,140 @@
+# Pulse Mobile Shell Redesign
+
+## What This Is
+
+A ground-up redesign of Pulse's `/mobile/*` shell — the manager-on-the-go view of
+the existing Pulse PSA dashboard. It replaces the current mobile layout, swaps
+the standalone `/mobile/nav` page for a Sheet drawer, restyles Dashboard /
+Tickets / Finance, and adds two new mobile surfaces (Analyzer feed,
+Engagement). Built on the existing Pulse codebase — same routes, same data,
+phone-first layouts.
+
+## Core Value
+
+A manager can open Pulse on their phone and, in under 30 seconds, see the
+state of the business and triage tickets — without ever needing to switch to
+desktop for read-only awareness.
+
+## Requirements
+
+### Validated
+
+
+
+- ✓ `/mobile` route shell with sticky header and bottom nav — existing
+- ✓ `/mobile/dashboard`, `/mobile/tickets`, `/mobile/tickets/[id]`,
+ `/mobile/finance` routes — existing
+- ✓ Standalone `/mobile/nav` page (to be replaced by drawer) — existing
+- ✓ Authentication, sync workers, analyzer pipeline, Engagement data sources —
+ existing in desktop app and reused here
+- ✓ PWA scaffolding (manifest, viewport-fit=cover, `pt-safe`/`pb-safe` utilities)
+ — Validated in Phase 1: PWA Scaffolding (PWA-01..04)
+- ✓ New `/mobile/layout.tsx` shell — sticky header (WulfMark + Bell placeholder
+ + avatar), 5-cell bottom nav (Dashboard/Tickets/Finance/Analyzer + More),
+ shared MoreDrawer Sheet — Validated in Phase 2: Mobile Shell + More Drawer
+ (SHELL-01..06, NAV-01..03)
+- ✓ More drawer (right Sheet) replaces `/mobile/nav` — Mobile sections, Full
+ site links (with `ExternalLink` hint), Account + Sign out — Validated in
+ Phase 2: Mobile Shell + More Drawer (DRAWER-01..06)
+- ✓ Dashboard restyle — 2×2 KPI grid, Needs Attention strip (horizontal scroll),
+ worker/backup status row, no charts. Backed by single `/api/mobile/dashboard`
+ endpoint returning `kpis`/`needsAttention`/`workers`. Validated in Phase 3:
+ Dashboard Restyle (DASH-01..04)
+
+### Active
+
+
+
+- [ ] Tickets restyle — collapsible filter strip (URL-synced), priority-bar
+ rows, cursor-based infinite scroll (~25/page), keep detail page
+- [ ] Finance restyle — adopt new Card + typography scale, swap wide tables for
+ stacked lists on mobile
+- [ ] Analyzer feed (NEW) — `/mobile/analyzer` read-only stream of recent AI
+ analyses with mobile summary view; new `/api/mobile/analyzer/feed`
+- [ ] Engagement mobile (NEW) — `/mobile/engagement` overview (period chips,
+ stacked summary cards, sortable per-employee list, sparkline) plus
+ `/mobile/engagement/[userId]` profile page replacing the desktop modal
+
+### Out of Scope
+
+
+
+- Service worker / offline cache / push notifications — deferred until a clear
+ offline use-case lands
+- Tablet breakpoint (`md:max-w-2xl`) — noted as follow-up, keep `max-w-lg`
+- Real notification list behind the Bell icon — placeholder only this iteration
+- Mobile editing on Engagement (user detail) or Analyzer (re-run, prompt edits)
+ — read-only on mobile by design
+- Charts / recharts on the mobile Dashboard — not earning their weight on
+ small widths
+- Restyling or replacing the desktop pages reachable from the More drawer —
+ desktop pages stay as they are
+
+## Context
+
+- **Brownfield project.** Pulse is a Next.js 16 + React 19 PSA dashboard for
+ Wulf Consulting. Existing codebase fully mapped at `.planning/codebase/*.md`.
+ See `CLAUDE.md`, `ARCHITECTURE.md`, `DESIGN.md` in repo root.
+- **Audience.** Wulf Consulting managers using Pulse on iOS/Android during the
+ workday — the shell is for status checks and triage, not full editing.
+- **Mobile is not a replacement for desktop.** Pages where mobile editing
+ isn't justified link out via the More drawer with an `ExternalLink` hint.
+- **Source of truth for this work.** `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
+ — 8-section spec with explicit build order. Phases should follow it.
+- **Existing mobile shell is small.** Current `app/mobile/layout.tsx` has 3
+ bottom-nav tabs and a `/mobile/nav` page. This redesign rebuilds it in
+ place — no `/mobile-v2`, no parallel routes.
+- **Engagement and Analyzer pages on desktop are large** (~1300 + ~650 lines
+ for engagement; analyzer pipeline already has a desktop UI). Mobile
+ surfaces reuse the data sources but build phone-first layouts from scratch.
+
+## Constraints
+
+- **Tech stack**: Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui — match
+ existing Pulse conventions (no new state libraries, no SWR/react-query, no
+ ORM, no Zod in API routes unless required)
+- **Routes**: Keep all existing `/mobile/*` paths. Replace files in place. No
+ new top-level routes outside `/mobile/`.
+- **Build order**: Each spec step ships independently — no big-bang merge.
+ Phase boundaries should let each step land on `master` cleanly.
+- **No service worker / no offline**: don't introduce `next-pwa` or a custom
+ SW in this iteration.
+- **Auth**: existing Better Auth + middleware handles `/mobile/*`. No new
+ auth surface.
+- **Data sources**: reuse existing endpoints where possible; add
+ `/api/mobile/*` only when an existing list endpoint doesn't return the
+ needed shape (e.g., analyzer feed).
+
+## Key Decisions
+
+
+
+| Decision | Rationale | Outcome |
+|----------|-----------|---------|
+| Rebuild `/mobile` in place (no `/mobile-v2`) | Spec §2 — keep canonical URLs, avoid parallel maintenance burden | — Pending |
+| Bottom nav = 4 tabs + More (5 cells), Engagement in More | Spec §3.1, §6.5 — managers don't check Engagement as often as the four primary surfaces | — Pending |
+| Ship each spec step as its own phase | Spec §8 — independent ship reduces merge risk and keeps reviews focused | — Pending |
+| Bell icon is a placeholder only | Spec §5.1 — real notification list deferred; keep keyboard-accessible button so future phase can wire it | — Pending |
+| No service worker in this iteration | Spec §4 — defer until a clear offline use-case lands | — Pending |
+| Engagement mobile is a real refactor, not a thin adaptation | Spec §6.5 — desktop's wide tables and modals don't translate; build phone-first from same data sources | — Pending |
+| Mobile user-detail is a page, not a modal | Spec §6.5 — back gesture needs real navigation history | — Pending |
+
+## Evolution
+
+This document evolves at phase transitions and milestone boundaries.
+
+**After each phase transition** (via `/gsd-transition`):
+1. Requirements invalidated? → Move to Out of Scope with reason
+2. Requirements validated? → Move to Validated with phase reference
+3. New requirements emerged? → Add to Active
+4. Decisions to log? → Add to Key Decisions
+5. "What This Is" still accurate? → Update if drifted
+
+**After each milestone** (via `/gsd-complete-milestone`):
+1. Full review of all sections
+2. Core Value check — still the right priority?
+3. Audit Out of Scope — reasons still valid?
+4. Update Context with current state
+
+---
+*Last updated: 2026-05-03 — Phase 3 complete (Dashboard Restyle)*
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
new file mode 100644
index 0000000..58f4de0
--- /dev/null
+++ b/.planning/REQUIREMENTS.md
@@ -0,0 +1,196 @@
+# Requirements: Pulse Mobile Shell Redesign
+
+**Defined:** 2026-05-03
+**Core Value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness.
+**Source spec:** `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
+
+## v1 Requirements
+
+Requirements for this milestone. Each maps to a spec section and a roadmap phase.
+
+### PWA — Progressive Web App scaffolding (spec §4)
+
+- [ ] **PWA-01**: `public/manifest.json` exists with name "Pulse", short_name "Pulse", `display: "standalone"`, `start_url: "/mobile"`, theme/background colors matching dark and light shells
+- [ ] **PWA-02**: Manifest is referenced from `app/layout.tsx` via ``
+- [ ] **PWA-03**: Viewport meta in `app/layout.tsx` includes `viewport-fit=cover`
+- [ ] **PWA-04**: Header and bottom tab bar respect `env(safe-area-inset-top)` and `env(safe-area-inset-bottom)` (Tailwind arbitrary values or shared utility class)
+
+### Shell — `/mobile` layout (spec §5)
+
+- [x] **SHELL-01**: New `app/mobile/layout.tsx` replaces the current layout (rebuild in place — no parallel `/mobile-v2`)
+- [ ] **SHELL-02**: Sticky top header: `bg-background/95 backdrop-blur` + bottom border; left = Wulf mark + "Pulse" wordmark linked to `/mobile/dashboard`; no page title in header
+- [ ] **SHELL-03**: Header right slot — `Bell` icon button (placeholder, no menu/badge, `aria-label="Notifications"`, empty `onClick`, keyboard-accessible)
+- [ ] **SHELL-04**: Header right slot — compact user avatar (`h-7 w-7`); tapping opens the More drawer
+- [x] **SHELL-05**: `` content area is scrollable with bottom padding equal to bottom-nav height + safe-area inset
+- [ ] **SHELL-06**: Fixed bottom nav: `border-t bg-background`, `max-w-lg mx-auto` wrapper, 5 cells (4 tabs + More)
+
+### NAV — Bottom tab bar (spec §3.1)
+
+- [ ] **NAV-01**: Four equal-width primary tabs: Dashboard (`LayoutDashboard`), Tickets (`Ticket`), Finance (`DollarSign`), Analyzer (`Sparkles`)
+- [ ] **NAV-02**: Tabs route to `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance`, `/mobile/analyzer`
+- [ ] **NAV-03**: Active state uses `text-primary`, inactive uses `text-muted-foreground`; active detection via `pathname.startsWith(href)`
+
+### DRAWER — More Sheet drawer (spec §3.2)
+
+- [ ] **DRAWER-01**: Fifth bottom-bar control labeled "More" with `Menu` icon opens a shadcn `Sheet`
+- [ ] **DRAWER-02**: Sheet uses a single consistent side (`right` or `bottom`) — pick one and stay consistent
+- [ ] **DRAWER-03**: Drawer top section "Mobile sections" lists Engagement (`/mobile/engagement`)
+- [ ] **DRAWER-04**: Drawer middle section "Full site" lists desktop-only pages (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync) each with `ExternalLink` icon
+- [ ] **DRAWER-05**: Drawer bottom section "Account" shows current user (avatar + email, read-only) and a Sign out action that calls `signOut()` then `router.push('/auth/sign-in')`
+- [x] **DRAWER-06**: `app/mobile/nav/page.tsx` is deleted in the same change that ships the drawer
+
+### DASH — Mobile Dashboard (spec §6.1)
+
+- [ ] **DASH-01**: 2×2 KPI grid with four primary metric cards drawn from desktop dashboard hero stats
+- [ ] **DASH-02**: "Needs Attention" horizontal-scroll strip of compact cards (overdue tickets, failed backups, stalled workflows); tapping a card opens its detail view
+- [ ] **DASH-03**: Compact backup/worker status row showing analyzer worker, RMM worker, and backup-success-rate; read-only; tap opens desktop admin page
+- [ ] **DASH-04**: No charts/recharts on the mobile Dashboard
+
+### TICK — Mobile Tickets list (spec §6.2)
+
+- [ ] **TICK-01**: Collapsible filter strip at top (`Collapsible` from shadcn), default collapsed; expanded shows status, priority, queue, assigned-to-me toggle
+- [ ] **TICK-02**: Filter state syncs to URL query string for deep-linking
+- [ ] **TICK-03**: List rows have left-edge color stripe by priority (Critical/High/Medium/Low → red/orange/amber/slate); body shows ticket #, title, company, age, assignee
+- [ ] **TICK-04**: Single-tap on a row opens detail page
+- [ ] **TICK-05**: Cursor-based infinite scroll (~25 per page) replaces pagination; next page triggers when last row enters viewport via IntersectionObserver
+- [ ] **TICK-06**: "Load more" fallback button present for accessibility
+- [ ] **TICK-07**: Detail page (`/mobile/tickets/[id]`) header reskinned to match new shell (Wulf mark, breadcrumb back); body kept largely as-is
+
+### FIN — Mobile Finance (spec §6.3)
+
+- [ ] **FIN-01**: Page restyled with new Card and typography scale; spacing fixed for small phones
+- [ ] **FIN-02**: Wide tables on mobile widths replaced with stacked lists; no new data, no new sections
+
+### ANL — Mobile Analyzer feed (spec §6.4) — NEW PAGE
+
+- [ ] **ANL-01**: `/mobile/analyzer` route exists (read-only feed, most-recent-first stream of AI ticket analyses)
+- [ ] **ANL-02**: Each list row shows ticket #, title, analyzer one-line summary, confidence badge, stage indicator (Triage → Analyze → Deep Review)
+- [ ] **ANL-03**: Tapping a row opens a mobile summary view rendering Summary, Next Step, Next Step Rationale (all already produced by the analyzer pipeline)
+- [ ] **ANL-04**: Summary view includes "View full analysis" link out to the desktop analyzer page
+- [ ] **ANL-05**: No editing, no re-run, no prompt tuning on mobile
+- [ ] **ANL-06**: Source data via `/api/mobile/analyzer/feed` (or reuse an existing list endpoint if it returns the right shape) reading from `analyzer_analyses`
+
+### ENG — Mobile Engagement (spec §6.5) — NEW PAGES
+
+- [ ] **ENG-01**: `/mobile/engagement` overview page (real refactor, not a thin adaptation of the ~1300-line desktop page)
+- [ ] **ENG-02**: Period selector chip row (today / 7d / 30d) sticky just below the page H1
+- [ ] **ENG-03**: Summary cards stacked single-column (active users, total Graph hours, total Autotask hours, hours-per-active-user) — no 4-up grid on phone widths
+- [ ] **ENG-04**: Per-employee list as stacked rows (avatar/initials, name, role, hours bar) with sort control above (sort by hours, name, utilization) and search input
+- [ ] **ENG-05**: Top of list shows compact "hours trend" sparkline scoped to the selected period; no multi-series chart on mobile
+- [ ] **ENG-06**: User profile is `/mobile/engagement/[userId]` (segment form preferred for shareable URLs); single-column layout: identity header → period selector → key metrics (compact) → activity breakdown list → recent items
+- [ ] **ENG-07**: User profile is a real page, not a modal — replaces desktop user-detail modal pattern on mobile so back gesture works
+- [ ] **ENG-08**: Profile reuses existing engagement profile data endpoints; no new data
+- [ ] **ENG-09**: Engagement is reachable from the More drawer, NOT the bottom bar
+
+## v2 Requirements
+
+Acknowledged but deferred. Not in this milestone's roadmap.
+
+### NOTIF — Notifications
+
+- **NOTIF-01**: Real notification list behind the Bell icon (replaces SHELL-03 placeholder)
+- **NOTIF-02**: Notification badge logic on the Bell icon
+
+### TABLET — Tablet breakpoint
+
+- **TABLET-01**: `md:max-w-2xl mx-auto` wrapper for tablet widths
+
+### OFFLINE — Offline support
+
+- **OFFLINE-01**: Service worker for offline cache
+- **OFFLINE-02**: Push notifications (requires SW)
+
+### EDIT — Mobile editing
+
+- **EDIT-01**: Mobile editing on Engagement user detail
+- **EDIT-02**: Mobile re-run / prompt edits on Analyzer
+
+## Out of Scope
+
+Explicitly excluded for v1. Documented to prevent scope creep.
+
+| Feature | Reason |
+|---------|--------|
+| Service worker / offline cache / push notifications | No clear offline use-case yet — defer until one lands (spec §4, §7) |
+| Tablet breakpoint (`md:max-w-2xl`) | Noted as follow-up; keep `max-w-lg` for v1 (spec §4, §7) |
+| Real notification list behind the Bell | Placeholder only this iteration; future phase wires it (spec §5.1, §7) |
+| Mobile editing on Engagement user detail | Read-only on mobile by design (spec §6.5, §7) |
+| Mobile re-run / prompt tuning on Analyzer | Read-only on mobile by design (spec §6.4, §7) |
+| Charts / recharts on mobile Dashboard | Not earning their weight on small widths (spec §6.1, §7) |
+| Restyling/replacing desktop pages reachable from More drawer | Desktop pages stay as-is (spec §7) |
+| Multi-series chart on mobile Engagement overview | Replaced by single sparkline (spec §6.5) |
+| Modal-based user detail on mobile | Replaced by real page so back gesture works (spec §6.5) |
+| `/mobile-v2` parallel directory | Rebuild `/mobile` in place — keep canonical URLs (spec §2) |
+
+## Traceability
+
+Updated during roadmap creation.
+
+| Requirement | Phase | Status |
+|-------------|-------|--------|
+| PWA-01 | Phase 1 | Pending |
+| PWA-02 | Phase 1 | Pending |
+| PWA-03 | Phase 1 | Pending |
+| PWA-04 | Phase 1 | Pending |
+| SHELL-01 | Phase 2 | Complete |
+| SHELL-02 | Phase 2 | Pending |
+| SHELL-03 | Phase 2 | Pending |
+| SHELL-04 | Phase 2 | Pending |
+| SHELL-05 | Phase 2 | Complete |
+| SHELL-06 | Phase 2 | Pending |
+| NAV-01 | Phase 2 | Pending |
+| NAV-02 | Phase 2 | Pending |
+| NAV-03 | Phase 2 | Pending |
+| DRAWER-01 | Phase 2 | Pending |
+| DRAWER-02 | Phase 2 | Pending |
+| DRAWER-03 | Phase 2 | Pending |
+| DRAWER-04 | Phase 2 | Pending |
+| DRAWER-05 | Phase 2 | Pending |
+| DRAWER-06 | Phase 2 | Complete |
+| DASH-01 | Phase 3 | Pending |
+| DASH-02 | Phase 3 | Pending |
+| DASH-03 | Phase 3 | Pending |
+| DASH-04 | Phase 3 | Pending |
+| TICK-01 | Phase 4 | Pending |
+| TICK-02 | Phase 4 | Pending |
+| TICK-03 | Phase 4 | Pending |
+| TICK-04 | Phase 4 | Pending |
+| TICK-05 | Phase 4 | Pending |
+| TICK-06 | Phase 4 | Pending |
+| TICK-07 | Phase 4 | Pending |
+| FIN-01 | Phase 5 | Pending |
+| FIN-02 | Phase 5 | Pending |
+| ANL-01 | Phase 6 | Pending |
+| ANL-02 | Phase 6 | Pending |
+| ANL-03 | Phase 6 | Pending |
+| ANL-04 | Phase 6 | Pending |
+| ANL-05 | Phase 6 | Pending |
+| ANL-06 | Phase 6 | Pending |
+| ENG-01 | Phase 7 | Pending |
+| ENG-02 | Phase 7 | Pending |
+| ENG-03 | Phase 7 | Pending |
+| ENG-04 | Phase 7 | Pending |
+| ENG-05 | Phase 7 | Pending |
+| ENG-09 | Phase 7 | Pending |
+| ENG-06 | Phase 8 | Pending |
+| ENG-07 | Phase 8 | Pending |
+| ENG-08 | Phase 8 | Pending |
+
+**Coverage:**
+- v1 requirements: 47 total
+- Mapped to phases: 47
+- Unmapped: 0 ✓
+
+**Per-phase counts:**
+- Phase 1 (PWA Scaffolding): 4 requirements
+- Phase 2 (Mobile Shell + More Drawer): 15 requirements
+- Phase 3 (Dashboard Restyle): 4 requirements
+- Phase 4 (Tickets Restyle): 7 requirements
+- Phase 5 (Finance Restyle): 2 requirements
+- Phase 6 (Analyzer Feed): 6 requirements
+- Phase 7 (Engagement Overview): 6 requirements
+- Phase 8 (Engagement User Profile): 3 requirements
+
+---
+*Requirements defined: 2026-05-03*
+*Last updated: 2026-05-03 — traceability filled in at roadmap creation*
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
new file mode 100644
index 0000000..d742777
--- /dev/null
+++ b/.planning/ROADMAP.md
@@ -0,0 +1,160 @@
+# Roadmap: Pulse Mobile Shell Redesign
+
+## Overview
+
+Eight phases mirror the deliberate build order in the source spec
+(`docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §8). Each phase
+ships independently to `master` — no big-bang merge. Phase 1 lays PWA
+metadata and safe-area utilities. Phase 2 rebuilds `app/mobile/layout.tsx`
+with the new header, 5-cell bottom nav, and More drawer (deleting
+`/mobile/nav` in the same change). Once the shell lands, Phases 3–7 are
+independent restyles/new pages and may be executed in parallel; Phase 8
+follows Phase 7 because the user profile is reached from the Engagement
+overview. All work happens in place under `/mobile/*` — no `/mobile-v2`,
+no parallel routes.
+
+## Phases
+
+**Phase Numbering:**
+- Integer phases (1, 2, 3): Planned milestone work
+- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
+
+Decimal phases appear between their surrounding integers in numeric order.
+
+- [ ] **Phase 1: PWA Scaffolding** — Manifest, viewport meta, and safe-area utilities so the shell installs and paints under the home indicator
+- [ ] **Phase 2: Mobile Shell + More Drawer** — New `app/mobile/layout.tsx` (header + 5-cell bottom nav) and Sheet drawer that replaces `/mobile/nav`
+- [ ] **Phase 3: Dashboard Restyle** — 2×2 KPI grid, Needs Attention strip, worker/backup status row (no charts)
+- [ ] **Phase 4: Tickets Restyle** — Collapsible URL-synced filters, priority-bar rows, cursor-based infinite scroll, detail header reskin
+- [ ] **Phase 5: Finance Restyle** — Adopt new Card + typography scale, swap wide tables for stacked lists
+- [ ] **Phase 6: Analyzer Feed (NEW)** — `/mobile/analyzer` read-only stream + `/api/mobile/analyzer/feed`
+- [ ] **Phase 7: Engagement Overview (NEW)** — `/mobile/engagement` phone-first overview reachable from the More drawer
+- [ ] **Phase 8: Engagement User Profile (NEW)** — `/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern
+
+## Phase Details
+
+### Phase 1: PWA Scaffolding
+**Goal**: A manager who taps "Add to Home Screen" gets a standalone Pulse icon that opens to the mobile shell with content respecting the device safe areas.
+**Depends on**: Nothing (first phase)
+**Requirements**: PWA-01, PWA-02, PWA-03, PWA-04
+**Success Criteria** (what must be TRUE):
+ 1. Visiting `/manifest.json` returns valid JSON with `name: "Pulse"`, `display: "standalone"`, `start_url: "/mobile"`, and theme/background colors matching the app shells
+ 2. The root `app/layout.tsx` references the manifest via `` and the viewport meta includes `viewport-fit=cover`
+ 3. A safe-area utility (Tailwind arbitrary values or shared class) is available so any sticky top/bottom bar can opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding
+ 4. Installing Pulse to a phone home screen launches a chromeless app pointed at `/mobile` (no service worker, no offline)
+**Plans**: 2 plans
+- [x] 01-01-PLAN.md — Web App Manifest + viewport-fit=cover (PWA-01, PWA-02, PWA-03)
+- [x] 01-02-PLAN.md — Safe-area `pt-safe` / `pb-safe` @utility blocks in brand.css (PWA-04, gap closure)
+**UI hint**: no
+
+### Phase 2: Mobile Shell + More Drawer
+**Goal**: Every `/mobile/*` page renders inside a new layout — sticky header (Wulf mark + Bell placeholder + avatar), scrollable content, and a 5-cell bottom nav whose fifth control opens a Sheet drawer that fully replaces `/mobile/nav`.
+**Depends on**: Phase 1
+**Requirements**: SHELL-01, SHELL-02, SHELL-03, SHELL-04, SHELL-05, SHELL-06, NAV-01, NAV-02, NAV-03, DRAWER-01, DRAWER-02, DRAWER-03, DRAWER-04, DRAWER-05, DRAWER-06
+**Success Criteria** (what must be TRUE):
+ 1. On any `/mobile/*` route the user sees a sticky header with the Wulf wordmark linking to `/mobile/dashboard`, a Bell icon button (keyboard-focusable, no menu), and a compact avatar — no page title in the header
+ 2. A fixed bottom bar exposes four primary tabs (Dashboard, Tickets, Finance, Analyzer) plus a More cell; tapping a tab routes to its page and the active tab uses `text-primary` based on `pathname.startsWith(href)`
+ 3. Tapping More (or the header avatar) opens a single Sheet drawer with three sections — Mobile sections (Engagement), Full site (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync — each with an `ExternalLink` hint), and Account (current user read-only + Sign out)
+ 4. Tapping Sign out in the drawer signs the user out and lands them on `/auth/sign-in`
+ 5. `app/mobile/nav/page.tsx` no longer exists; visiting `/mobile/nav` does not render the old standalone nav page
+ 6. Page content scrolls under the sticky header and is not hidden behind the bottom nav (bottom padding accounts for nav height + safe-area inset)
+**Plans**: 2 plans
+- [x] 02-01-PLAN.md — Build mobile shell components (HeaderBar, BottomNav, MoreDrawer) + analyzer placeholder (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05)
+- [x] 02-02-PLAN.md — Wire new components into app/mobile/layout.tsx, delete app/mobile/nav/page.tsx (SHELL-01, SHELL-05, DRAWER-06)
+**UI hint**: yes
+
+### Phase 3: Dashboard Restyle
+**Goal**: A manager opening `/mobile/dashboard` sees the state of the business at a glance — four KPIs, items needing attention, and a worker/backup status row — with no charts.
+**Depends on**: Phase 2
+**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04
+**Success Criteria** (what must be TRUE):
+ 1. Dashboard renders a 2×2 grid of four primary KPI cards drawn from desktop hero stats (no 1×4 row, no charts)
+ 2. Below the grid, a "Needs Attention" horizontally-scrollable strip surfaces overdue tickets, failed backups, and stalled workflows; tapping a card opens its detail view
+ 3. A compact status row shows analyzer worker, RMM worker, and backup-success-rate; tapping any element opens the corresponding desktop admin page
+ 4. The page contains no recharts/chart components on phone widths
+**Plans**: 2 plans
+- [x] 03-01-PLAN.md — /api/mobile/dashboard reshape + KpiCardMobile/NeedsAttentionStrip/WorkerStatusRow components (DASH-01, DASH-02, DASH-03)
+- [x] 03-02-PLAN.md — Replace /mobile/dashboard page body with 3-section layout, no charts (DASH-01, DASH-02, DASH-03, DASH-04)
+**UI hint**: yes
+
+### Phase 4: Tickets Restyle
+**Goal**: A manager triages tickets on a phone with a collapsible filter bar that deep-links via URL, priority-coloured rows, and infinite scroll — and the detail page header matches the new shell.
+**Depends on**: Phase 2
+**Requirements**: TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06, TICK-07
+**Success Criteria** (what must be TRUE):
+ 1. The Tickets page opens with the filter strip collapsed; expanding it reveals status, priority, queue, and an assigned-to-me toggle, and changing any filter updates the URL query string (deep link works on reload)
+ 2. Each list row has a left-edge stripe matching priority (Critical/High/Medium/Low → red/orange/amber/slate) and shows ticket #, title, company, age, and assignee
+ 3. Single-tapping a row navigates to `/mobile/tickets/[id]`
+ 4. Scrolling to the bottom of the list automatically loads the next ~25 rows (no Next button); a "Load more" fallback button is also visible/focusable for accessibility
+ 5. The detail page header uses the new shell styling (Wulf mark, breadcrumb back) while the body remains largely unchanged
+**Plans**: 3 plans
+- [ ] 04-01-PLAN.md — /api/mobile/tickets cursor rewrite + TicketFilterStrip + TicketRowSkeleton components (TICK-01, TICK-02, TICK-05)
+- [ ] 04-02-PLAN.md — Replace app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll (TICK-01..TICK-06)
+- [ ] 04-03-PLAN.md — Reskin in-page header of app/mobile/tickets/[id]/page.tsx (back chevron + breadcrumb + ExternalLink) (TICK-07)
+**UI hint**: yes
+
+### Phase 5: Finance Restyle
+**Goal**: A manager reading AR / invoice / payment status on a phone sees properly spaced cards and stacked lists instead of squished wide tables — same data, new shell.
+**Depends on**: Phase 2
+**Requirements**: FIN-01, FIN-02
+**Success Criteria** (what must be TRUE):
+ 1. `/mobile/finance` adopts the new Card and typography scale — no horizontal overflow, spacing legible on small phones
+ 2. Sections that previously rendered wide tables on phone widths now render as stacked lists (no new sections, no new data sources)
+**Plans**: TBD
+**UI hint**: yes
+
+### Phase 6: Analyzer Feed (NEW)
+**Goal**: A manager taps the Analyzer tab and skims a most-recent-first stream of AI ticket analyses, opening any one to a phone-friendly summary view that links out to desktop for full details.
+**Depends on**: Phase 2
+**Requirements**: ANL-01, ANL-02, ANL-03, ANL-04, ANL-05, ANL-06
+**Success Criteria** (what must be TRUE):
+ 1. Tapping the Analyzer tab in the bottom nav lands on `/mobile/analyzer` and shows a most-recent-first list of AI ticket analyses
+ 2. Each row shows ticket #, title, the analyzer's one-line summary, a confidence badge, and a stage indicator (Triage → Analyze → Deep Review)
+ 3. Tapping a row opens a mobile summary view rendering Summary, Next Step, and Next Step Rationale, with a "View full analysis" link out to the desktop analyzer page
+ 4. The mobile feed never exposes editing, re-run, or prompt-tuning controls (read-only by design)
+ 5. The list reads from `analyzer_analyses` via `/api/mobile/analyzer/feed` (or a reused list endpoint that already returns the right shape)
+**Plans**: TBD
+**UI hint**: yes
+
+### Phase 7: Engagement Overview (NEW)
+**Goal**: A manager reaches Engagement from the More drawer and sees a phone-first overview — period chips, stacked summary cards, a sortable per-employee list, and one compact sparkline.
+**Depends on**: Phase 2
+**Requirements**: ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09
+**Success Criteria** (what must be TRUE):
+ 1. The Mobile sections row in the More drawer links to `/mobile/engagement`; the Analyzer is on the bottom bar but Engagement is not
+ 2. The overview page shows a period selector (today / 7d / 30d) sticky just below the H1, with active period clearly indicated
+ 3. Summary cards (active users, total Graph hours, total Autotask hours, hours-per-active-user) render single-column stacked — no 4-up grid on phone widths
+ 4. The per-employee list renders as stacked rows (avatar/initials, name, role, hours bar) with a search input and a sort control above (sort by hours, name, utilization)
+ 5. A single compact "hours trend" sparkline renders at the top of the list, scoped to the selected period — no multi-series chart
+**Plans**: TBD
+**UI hint**: yes
+
+### Phase 8: Engagement User Profile (NEW)
+**Goal**: From the Engagement overview, a manager taps an employee row and arrives at a real, shareable profile page — single-column phone-first — and the device back gesture returns them to the overview.
+**Depends on**: Phase 7
+**Requirements**: ENG-06, ENG-07, ENG-08
+**Success Criteria** (what must be TRUE):
+ 1. Tapping a row in the per-employee list navigates to `/mobile/engagement/[userId]` (segment form, shareable URL)
+ 2. The profile is a real page (not a modal) — the device/browser back gesture returns to the overview at the same scroll position
+ 3. The profile renders single-column: identity header → period selector → key metrics (compact) → activity breakdown list → recent items, sourced from the existing engagement profile data endpoints (no new data)
+**Plans**: TBD
+**UI hint**: yes
+
+## Progress
+
+**Execution Order:**
+Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, parallelizable). Phase 8 follows Phase 7.
+
+| Phase | Plans Complete | Status | Completed |
+|-------|----------------|--------|-----------|
+| 1. PWA Scaffolding | 1/2 | Executing | - |
+| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - |
+| 3. Dashboard Restyle | 0/2 | Not started | - |
+| 4. Tickets Restyle | 0/3 | Not started | - |
+| 5. Finance Restyle | 0/TBD | Not started | - |
+| 6. Analyzer Feed | 0/TBD | Not started | - |
+| 7. Engagement Overview | 0/TBD | Not started | - |
+| 8. Engagement User Profile | 0/TBD | Not started | - |
+
+---
+*Roadmap created: 2026-05-03*
+*Source spec: `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`*
diff --git a/.planning/STATE.md b/.planning/STATE.md
new file mode 100644
index 0000000..86f631e
--- /dev/null
+++ b/.planning/STATE.md
@@ -0,0 +1,85 @@
+---
+gsd_state_version: 1.0
+milestone: v1.0
+milestone_name: milestone
+status: executing
+stopped_at: Completed 02-mobile-shell-more-drawer/02-02-PLAN.md
+last_updated: "2026-05-03T21:09:09.113Z"
+last_activity: 2026-05-03
+progress:
+ total_phases: 8
+ completed_phases: 3
+ total_plans: 6
+ completed_plans: 6
+ percent: 100
+---
+
+# Project State
+
+## Project Reference
+
+See: .planning/PROJECT.md (updated 2026-05-03)
+
+**Core value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness.
+**Current focus:** Phase 03 — dashboard-restyle
+
+## Current Position
+
+Phase: 4
+Plan: Not started
+Status: Executing Phase 03
+Last activity: 2026-05-03
+
+Progress: [░░░░░░░░░░] 0%
+
+## Performance Metrics
+
+**Velocity:**
+
+- Total plans completed: 6
+- Average duration: —
+- Total execution time: 0.0 hours
+
+**By Phase:**
+
+| Phase | Plans | Total | Avg/Plan |
+|-------|-------|-------|----------|
+| 01 | 2 | - | - |
+| 02 | 2 | - | - |
+| 03 | 2 | - | - |
+
+**Recent Trend:**
+
+- Last 5 plans: —
+- Trend: —
+
+*Updated after each plan completion*
+| Phase 02-mobile-shell-more-drawer P02 | 8 | 4 tasks | 2 files |
+
+## Accumulated Context
+
+### Decisions
+
+Decisions are logged in PROJECT.md Key Decisions table.
+Recent decisions affecting current work:
+
+- Roadmap: Phases mirror the spec's 8-step build order so each step ships independently to `master` (spec §8)
+- Phase 2 unblocks Phases 3–7; Phases 3–7 are mutually independent and can be parallelized; Phase 8 depends on Phase 7
+- All work happens in place under `/mobile/*` — no `/mobile-v2`, no parallel routes (spec §2)
+- [Phase 02-mobile-shell-more-drawer]: Single useState in mobile layout.tsx for drawer open state — no Zustand/Context per CLAUDE.md constraint
+- [Phase 02-mobile-shell-more-drawer]: Tailwind 4 pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))] arbitrary value works without inline-style fallback
+- [Phase 02-mobile-shell-more-drawer]: No redirect on /mobile/nav deletion — standard 404 per DRAWER-06 spec
+
+### Pending Todos
+
+None yet.
+
+### Blockers/Concerns
+
+None yet.
+
+## Session Continuity
+
+Last session: 2026-05-03T20:12:16.872Z
+Stopped at: Completed 02-mobile-shell-more-drawer/02-02-PLAN.md
+Resume file: None
diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md
new file mode 100644
index 0000000..cffe369
--- /dev/null
+++ b/.planning/codebase/ARCHITECTURE.md
@@ -0,0 +1,211 @@
+# Architecture
+
+**Analysis Date:** 2026-05-03
+
+## Pattern Overview
+
+**Overall:** Single-instance Next.js 16 backend with in-process background workers (no external job queue).
+
+**Key Characteristics:**
+- App Router pages (`'use client'`) fetch data via API routes using `fetch()`
+- Three auto-starting background workers via side-effect imports (sync-scheduler, analyzer, RMM overshell)
+- Service layer in `lib/services/` with factories + configuration helpers
+- Postgres 16 as system of record (snake_case columns), Redis for caching only
+- Manual transformation from snake_case DB columns to camelCase API responses
+- Better Auth 1.4 for sessions + magic link + TOTP 2FA + Microsoft OAuth
+
+## Layers
+
+**Route Layer (HTTP entry):**
+- Purpose: Accept HTTP requests, validate auth, delegate to services, return JSON responses
+- Location: `app/api/*/route.ts`, `app/*/page.tsx`
+- Contains: Next.js route handlers (GET/POST/PATCH/DELETE), page components
+- Depends on: Auth via `lib/auth-utils.ts`, services via `lib/services/`
+- Used by: Client-side fetch calls from UI components
+
+**Service Layer (business logic):**
+- Purpose: Sync data from external APIs, run background jobs, execute workflows, store results in Postgres
+- Location: `lib/services/*.ts` (~50 files) plus subdirectories (`analyzer/`, `rmm/`, `llm/`, `b2/`)
+- Contains: Integration clients (autotask, datto-rmm, itglue, veeam, msgraph, etc.), sync logic, job executors, pipeline orchestration
+- Depends on: Postgres client, external API clients, environment configuration
+- Used by: API routes (sync endpoints, webhook handlers) and background workers
+
+**Data Access Layer (Postgres):**
+- Purpose: Query, upsert, and manage state in Postgres
+- Location: `lib/services/postgres-client.ts` (singleton) + `migrations/NNN_*.sql`
+- Contains: Connection pool, query builder methods (`query()`, `transaction()`, `upsert()`, `bulkUpsert()`), migration definitions
+- Depends on: PostgreSQL 16 connection string from env
+- Used by: All services that read/write data
+
+**Background Workers (long-running processes):**
+- Purpose: Poll for work and execute sync/analysis/RMM tasks without blocking HTTP requests
+- Location: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`
+- Contains: node-cron scheduler, polling loops with exponential backoff, state machine handlers
+- Depends on: Postgres client, service layer integrations
+- Used by: Auto-start on module import (side effects); HTTP routes that need them running will import the module to start them
+
+**Component Layer (UI):**
+- Purpose: Render pages, dialogs, tables, charts, and handle client-side state
+- Location: `components/ui/` (shadcn primitives), `components/*/` (feature-specific), `app/*/page.tsx`
+- Contains: React components, hooks for fetch + state, sonner toasts, recharts visualizations
+- Depends on: API routes via `fetch()`, client auth via Better Auth SDK
+- Used by: Next.js pages and other components
+
+**Types & Schema (contracts):**
+- Purpose: Define TypeScript interfaces and database schema
+- Location: `lib/types/.ts`, `migrations/NNN_*.sql`
+- Contains: Entity types (ticket, company, analysis, rmm_execution, etc.), sync request shapes, API response envelopes
+- Depends on: (none — they define contracts)
+- Used by: Services, API routes, components
+
+## Data Flow
+
+**Autotask Webhook → Analyzer Pipeline:**
+
+1. **Webhook ingress** (`POST /api/webhooks/autotask`) — Public endpoint (no auth), HMAC-verified inside handler via `lib/services/webhook-service.ts`
+2. **Enqueue job** — If ticket.created, fires webhook handler in `lib/services/webhook-service.ts` which inserts `analyzer_jobs` row with `status='queued'`
+3. **Worker poll** — `lib/services/analyzer/worker.ts` auto-starts in production; every 2s polls for `analyzer_jobs.status='queued'`, claims one with `FOR UPDATE SKIP LOCKED`
+4. **Pipeline orchestration** — `lib/services/analyzer/pipeline.ts` runs 7 stages:
+ - Stage 0: Preprocess (filter noise, compute `content_hash` for idempotency)
+ - Stage 1: Triage (Haiku — categorize, extract entities)
+ - Stage 2: IT Glue retrieval (if configured, redacted docs only via `itglue-search.ts`)
+ - Stage 3: Deep analysis (Sonnet — summary, gaps, root cause)
+ - Stage 4: Deep reasoning (Opus — optional, skipped above $2.00 cost ceiling)
+ - Stage 5: Persist to `analyzer_analyses` + stage execution rows
+ - Stage 6: Fingerprint (Haiku — cross-ticket aggregation data)
+5. **Link-aware bundles** — `lib/services/analyzer/link-discovery.ts` resolves related tickets; members get `pending_analyses` rows
+6. **Aggregate reports** (optional) — Once all bundle members analyzed, `stages/aggregate-reduce.ts` fires
+
+**Periodic Sync Cadence:**
+
+1. **Cron trigger** — `lib/services/sync-scheduler.ts` (node-cron singleton) reads `sync_schedules` table; fires at configured times
+2. **Entity sync** — `lib/services/entity-sync.ts` per entity type (tickets, companies, resources, etc.), incremental via `lastTrackedModificationDateTime` when supported
+3. **Postgres upsert** — `postgresClient.bulkUpsert()` writes batches to DB tables (`tickets`, `companies`, etc.)
+4. **Integration-specific syncs** — Datto RMM devices/alerts, IT Glue configs/contacts, Veeam agents/alarms, Engagement data, Zoom, Duo, etc.
+
+**RMM Overshell Execution:**
+
+1. **User trigger** (`POST /api/rmm/execute`) — Admin user picks registered script + device
+2. **Validation & rate limit** — `lib/services/rmm/executor.ts` validates script ID, resolves device, enforces per-user limit (50 / 24h)
+3. **Queue insertion** — Insert `pending` row in `rmm_executions` table, call Datto `client.runQuickJob()`
+4. **Worker poll** — `lib/services/rmm/worker.ts` (5s cadence) polls in-flight executions, queries Datto for result status
+5. **Output parsing** — Script's `parseOutput()` method transforms Datto output; result stored in `rmm_executions`
+
+**LogLift Evidence Ingest:**
+
+1. **Webhook** (`POST /api/rmm/loglift/upload`) — Public, `x-openclaw-key` header auth
+2. **Decompression** — Download gzipped JSON from B2, decompress, cap at 100 MB (zip-bomb guard)
+3. **Storage** — Slim summary to `loglift_uploads` table, full payload to B2 via `lib/services/b2/client.ts`
+4. **Auto-audit** — Resolve device → Autotask company → IT Glue config; if unique match, fire asset-first audit
+
+**IT Glue Write-back:**
+
+1. **Audit runner** (`lib/services/analyzer/asset-audit/runner.ts`) — Post-analysis, runs LLM audit against IT Glue config/flexible asset
+2. **Results** — Persisted to `itglue_audit_logs`, linked via `itglue_ticket_xrefs`
+3. **Revert** — Patch `/api/analyzer/itglue/configurations/[id]/revert/[writeId]` rolls back changes
+
+**State Management:**
+
+- **HTTP requests:** Stateless; session from Better Auth cookie
+- **Background jobs:** State in Postgres (status columns: `queued` → `in_flight` → `complete` / `failed`)
+- **Caching:** Redis (optional, used for integration health checks, cache duration varies)
+- **Authorization:** Checked in route handlers via `requireAuth()` / `requireAdmin()` / `requirePermission()` from `lib/auth-utils.ts`
+
+## Key Abstractions
+
+**Factory Pattern (Integration Clients):**
+- Purpose: Lazy-load integration clients with configured credentials; provide `isConfigured()` helper to check env vars
+- Examples: `lib/services/autotask-factory.ts`, `lib/services/datto-rmm-factory.ts`, `lib/services/msgraph-factory.ts`
+- Pattern: Export `getAutotaskClient()`, `getDattoRmmClient()`, `getMsgraphClient()` with caching; throw if credentials missing; bundled with `isConfigured()` for upstream checks
+- Why: Decouples client initialization from route handlers; allows conditional feature gates per env
+
+**PostgresClient Singleton:**
+- Purpose: Single connection pool for all Postgres queries; auto-lazy-initializes; provides ORM-like query builder
+- Examples: `postgresClient.query()`, `postgresClient.transaction()`, `postgresClient.upsert()`, `postgresClient.bulkUpsert()`
+- Pattern: Private constructor, static `getInstance()`, pool initialized on first use
+- Why: Prevents connection leaks; provides consistent interface across ~50 services
+
+**Sync Service (Entity-Agnostic):**
+- Purpose: Incremental/full sync of any entity type from external API → Postgres
+- Examples: `lib/services/entity-sync.ts` (Autotask), `lib/services/itglue-sync-service.ts`, `lib/services/veeam-sync-service.ts`
+- Pattern: Reads `lastTrackedModificationDateTime` from last_sync table; queries external API; batches upsert via `bulkUpsert()`
+- Why: Codifies the "last sync timestamp + incremental pull + batch insert" pattern across integrations
+
+**Analyzer Pipeline (7 Stages):**
+- Purpose: Orchestrate multi-stage LLM analysis with fallbacks, cost guards, and persistence
+- Examples: `lib/services/analyzer/pipeline.ts`, `lib/services/analyzer/stages/*.ts`
+- Pattern: Each stage returns structured output (gap analysis, category, cost estimate); cost guard checks ceiling before Stage 4; all stages persisted to `analyzer_stage_executions`
+- Why: Allows cost control (skip expensive Opus above $2.00), idempotency (content_hash), and debugging (inspect each stage's output)
+
+**Background Worker Polling Loop:**
+- Purpose: Auto-start in production, poll for work, claim rows with `FOR UPDATE SKIP LOCKED`, execute, persist result
+- Examples: `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`, `lib/services/sync-scheduler.ts`
+- Pattern: Side-effect import auto-starts on module load; exponential backoff if no work; row locking for multi-instance safety (analyzer), or single-instance gate (sync scheduler)
+- Why: Keeps background work out of HTTP request path; analyzer is safe to scale (row locking); sync scheduler should run on one instance only
+
+**Better Auth Roles (RBAC):**
+- Purpose: Define three roles (`user`, `admin`, `super-admin`) with per-resource permissions
+- Examples: `lib/auth.ts` (auth config), `lib/permissions.ts` (permission matrix), `lib/auth-utils.ts` (runtime checks)
+- Pattern: Route handler calls `requireAdmin()` / `requirePermission()`, which decode session and check role; middleware only verifies session cookie exists
+- Why: Separates auth (middleware) from authorization (route handler); role-based gates are checked at the point of use
+
+## Entry Points
+
+**HTTP Pages (Authenticated):**
+- Location: `app/*/page.tsx`
+- Triggers: Browser navigation to any route except public paths
+- Responsibilities: Render page shell with `AppNavigation` + `PageHeader`, fetch data from API, render client-side components, handle toast/dialog interactions
+
+**HTTP API Routes (Public & Authenticated):**
+- Location: `app/api/*/route.ts`
+- Triggers: `fetch()` from client, external webhooks (Autotask, Zabbix, RMM), scheduler HTTP calls
+- Responsibilities: Validate auth/webhook signature, delegate to service layer, return JSON response with appropriate status (200, 401, 403, 503, 500)
+
+**Webhook Handlers (Public):**
+- Location: `app/api/webhooks/autotask`, `app/api/zabbix/webhook`, `app/api/rmm/loglift`
+- Triggers: External systems (Autotask, Zabbix, OpenClaw) POST events
+- Responsibilities: Verify HMAC or custom header, parse event, enqueue jobs or upsert data, return 200 (even on error so Autotask doesn't deactivate)
+
+**Sync Endpoints (Public, called by scheduler):**
+- Location: `app/api/sync/*`, `app/api/datto-rmm/sync`, `app/api/itglue/sync`, `app/api/veeam/sync`, etc.
+- Triggers: `lib/services/sync-scheduler.ts` fires HTTP POST at configured times
+- Responsibilities: Call sync service, update `last_sync` timestamp, return 200 on success or error message
+
+**Background Workers (Auto-starting, in-process):**
+- Location: `lib/services/sync-scheduler.ts` (cron), `lib/services/analyzer/worker.ts` (2s poll), `lib/services/rmm/worker.ts` (5s poll)
+- Triggers: Auto-starts as side effect of module import; runs indefinitely in production
+- Responsibilities: Poll for work from DB, claim row, execute, persist result, handle errors + logging
+
+## Error Handling
+
+**Strategy:** Defensive; assume external APIs can fail, return 200 on webhook failures (so Autotask doesn't deactivate), log all errors, flag analyses for human review if cost ceiling exceeded.
+
+**Patterns:**
+
+- **Webhook handlers** — Return 200 even if processing fails; log error so ops can investigate via audit log
+- **API route handlers** — `try/catch`, return `NextResponse.json({ error, message }, { status })` with conventions:
+ - 401: Session missing or invalid
+ - 403: Authenticated but lacks permission
+ - 503: Missing/bad integration config (e.g., Autotask API key not set)
+ - 500: Runtime error (query failed, external API timeout, etc.)
+- **Analyzer pipeline** — Cost ceiling guard at $2.00; above that, Stage 4 (Opus) skipped, analysis flagged with `needs_review=true`
+- **Sync services** — Incremental sync errors log + re-trigger on next schedule; full sync errors persist `last_error` to `sync_schedules` table
+- **Background workers** — Stale jobs reset on worker boot (no recovery); exponential backoff on empty polls; errors logged with job ID for manual inspection
+
+## Cross-Cutting Concerns
+
+**Logging:** Console (stdout) in all services; elevated to syslog or Datadog in production. Analyzer logs all LLM calls + cost to `analyzer_cost_audit` table for billing reconciliation.
+
+**Validation:** Explicit checks in route handlers where it matters (e.g., device ID exists before RMM execute); no centralized validation framework. Zod used for auth/admin forms only.
+
+**Authentication:** Better Auth session stored in Postgres; cookie `Auth` + `Auth.Secure` sent on all requests. `middleware.ts` verifies session cookie exists for authenticated pages. Role checks happen in route handlers.
+
+**Authorization:** Per-resource permissions defined in `lib/permissions.ts` (tickets, configItems, admin, users, roles, auditLog, settings, itglue, rmm). API routes call `requirePermission(resource, action)` to enforce. UI hides links based on `session.user.role`.
+
+**Rate Limiting:** RMM execute endpoint has per-user limit (50 scripts / 24h via `lib/services/rate-limiter.ts`). No global rate limiter.
+
+**Integration Health:** `lib/services/integration-health.ts` polls each integration's health (e.g., Autotask token expiry, last sync age); stores status in `integration_health` table; fires alerts if integration is down or sync is stale.
+
+---
+
+*Architecture analysis: 2026-05-03*
diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md
new file mode 100644
index 0000000..d2cefa1
--- /dev/null
+++ b/.planning/codebase/CONCERNS.md
@@ -0,0 +1,226 @@
+# Codebase Concerns
+
+**Analysis Date:** 2026-05-03
+
+## Critical Database Access Pattern
+
+**Widespread Pool Instantiation:**
+- Issue: Multiple new Pool instances created directly in files instead of using the postgresClient singleton
+- Files:
+ - `app/admin/users/[id]/page.tsx` (line 11)
+ - `app/admin/roles/[id]/page.tsx` (line 7)
+ - `app/api/data/time-entries/route.ts` (line 6)
+ - `app/api/admin/audit-log/route.ts` (line 5)
+ - `app/api/admin/settings/route.ts` (line 6)
+ - `app/api/admin/users/route.ts` (line 5)
+ - `app/api/admin/users/[id]/route.ts` (line 5)
+ - `app/api/admin/users/[id]/sessions/route.ts` (line 5)
+ - `app/api/admin/users/[id]/sessions/[sessionId]/route.ts` (line 5)
+ - `app/api/admin/users/invite/route.ts` (line 6)
+ - `app/api/admin/roles/route.ts` (line 5)
+ - `app/api/admin/roles/[id]/route.ts` (line 5)
+ - `app/api/rmm-devices/route.ts` (line 13)
+ - `app/api/openclaw/datto-rmm/devices/route.ts` (line 6)
+ - `app/api/openclaw/datto-rmm/devices/[uid]/route.ts` (line 6)
+ - `app/api/openclaw/datto-rmm/alerts/route.ts` (line 6)
+ - `app/api/openclaw/datto-rmm/sites/route.ts` (line 6)
+ - `app/api/openclaw/datto-rmm/alerts/open/route.ts` (line 6)
+ - `app/api/addigy/org-mappings/route.ts` (line 6)
+ - `app/api/settings/profile/route.ts` (line 5)
+ - `app/api/auvik/tenant-mappings/route.ts` (line 6)
+ - `app/api/rmm/site-mappings/route.ts` (line 6)
+ - `lib/bootstrap.ts` (line 3)
+ - `lib/auth.ts` (line 9)
+ - `lib/services/audit.ts` (line 3)
+ - `lib/services/auvik-client.ts` (line 216)
+- Impact: Each Pool() call creates a new connection pool, consuming resources and database connections. In production with multiple instances, this can exhaust connection limits. No centralized control over connection pooling.
+- Fix approach: Replace all instances with `postgresClient` singleton from `lib/services/postgres-client.ts`. The singleton implements lazy initialization and reuses the same pool. Create a migration script to audit all imports and replace new Pool() with postgresClient imports.
+
+## Duplicate Migration Numbers
+
+**Out-of-Order Migrations:**
+- Issue: Multiple migrations share the same number prefix, causing alphabetical apply order to diverge from intent
+- Files: `migrations/`
+- Duplicates found:
+ - `002_add_indexes.sql` and `002_relax_foreign_keys.sql`
+ - `004_fix_contacts_company_id.sql` and `004_webhook_support.sql`
+ - `005_add_webhook_ip_logging.sql` and `005_fix_tickets_company_id.sql`
+ - `009_create_auvik_tenant_mappings.sql`, `009_relax_configuration_items_constraints.sql`, `009_restore_deleted_tickets.sql`
+ - `028_create_device_lifecycle_policies.sql` and `028_create_veeam_agents_alarms.sql`
+ - `049_create_ping_flap_suppressions.sql` and `049_create_ticket_digest_tables.sql`
+ - `057_contacts_missing_fields.sql` and `057_create_autotask_tags_tables.sql`
+ - `058_create_duo_tables.sql` and `058_create_repo_commit_tracking.sql`
+- Impact: Alphabetical filesystem sort determines execution order (not numeric). On Postgres init, migrations apply in ASCII order. New environment setups may fail if a constraint or schema change in one migration depends on another with the same number. This is a hidden fragility.
+- Fix approach: Rename all duplicate migrations to sequential numbers (e.g., `009_create_auvik_tenant_mappings.sql`, `010_relax_configuration_items_constraints.sql`, `011_restore_deleted_tickets.sql`). Verify that the alphabetical apply order would work for existing databases. Test full init sequence on fresh Postgres. Update deployment docs to warn against re-numbering.
+
+## Extensive console.log in Production Code
+
+**Unstructured Logging:**
+- Issue: `console.log()` statements left throughout production code instead of proper logging framework
+- Files with multiple instances:
+ - `lib/utils/sync-helpers.ts` (lines 344, 417, 441)
+ - `lib/services/redis-client.ts` (lines 7, 34, 95)
+ - `lib/services/background-processor.ts` (lines 185, 224, 255, 272, 319, 327, 335)
+ - `lib/services/duo-sync-service.ts` (lines 53, 64, 70, 95, 139, 591)
+ - `lib/services/workflow-engine.ts` (lines 104, 221, 326, 664)
+ - `lib/services/duo-client.ts` (line 165)
+ - `lib/services/itglue-sync-service.ts` (line 54)
+ - `lib/services/veeam-sync-service.ts` (lines 91, 112, 127)
+ - `lib/services/webhook-service.ts` (lines 71, 76, 110, 164, 193, 233, 429)
+ - `lib/services/veeam-factory.ts` (line 30)
+ - `lib/services/veeam-compliance-service.ts` (lines 25, 50, 190, 191)
+- Impact: Logs go directly to stdout, not aggregated to a logging service. In production, container logs are hard to filter and correlate. No structured metadata (timestamp in some, not others; inconsistent prefixes). Search for "WEBHOOK" or "VEEAM-SYNC" is the only way to filter.
+- Fix approach: Create a simple logger module at `lib/logger.ts` with methods `.info()`, `.warn()`, `.error()` that preserve prefixes but add timestamps and structure. Replace all `console.log()` with `logger.info()` etc. Consider using pino or winston if needed in future, but start with a lightweight wrapper.
+
+## Tech Debt: Worker Side-Effect Imports
+
+**Hidden Auto-Initialization:**
+- Issue: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, and `lib/services/rmm/worker.ts` auto-start on import as a side effect
+- Files:
+ - `lib/services/sync-scheduler.ts` (bottom of file)
+ - `lib/services/analyzer/worker.ts` (auto-starts in production or if `ANALYZER_WORKER_AUTOSTART=1`)
+ - `lib/services/rmm/worker.ts` (same gates as analyzer)
+- Impact: Importing these modules from shared utilities (e.g., a shared auth helper) will start worker loops unexpectedly. If a hot path accidentally imports one, it runs polling immediately. The analyzer worker uses `SELECT … FOR UPDATE SKIP LOCKED` so it's safe across instances, but the sync scheduler does NOT — running it on multiple instances causes duplicate syncs.
+- Fix approach: Add comments on all three modules warning against hot-path imports. Consider a factory pattern: `initSyncScheduler()`, `startAnalyzerWorker()` with explicit function calls instead of side effects. Or gate them behind a feature flag that must be explicitly enabled. Document in CLAUDE.md that only `app/api/*/route.ts` handlers should import these. Add a linting rule if possible.
+
+## Type Safety in Shared Types
+
+**Excessive use of `any`:**
+- Issue: Shared type definitions use `any` for workflow/pipeline configuration and error handling
+- Files:
+ - `lib/types/ticket-workflow.ts` (lines 48, 78, 84, 100, 104, 107 — field_changes, condition values, validation errors)
+ - `lib/types/pipeline.ts` (lines 34, 128 — stage values, context dict)
+ - `lib/types/workflow.ts` (lines 108, 111, 113, 139, 193, 207, 243, 245, 273, 293-294, 358, 361, 363, 384 — match_value, result_value, field_changes)
+ - `lib/types/datto-rmm.ts` (lines 105, 141, 146-147 — autotaskDevice, field mapping)
+ - `lib/types/errors.ts` (lines 148, 229, 241 — error categorization and formatting)
+- Impact: Configuration values for workflow conditions/actions are not validated at the type level. A malformed workflow rule with `match_value: 123` (number instead of string/regex) will only fail at runtime. Error handling functions that accept `any` can mask type errors silently.
+- Fix approach: Use TypeScript discriminated unions or explicit types for workflow values. For errors, define a proper error interface and use type guards. Start with `lib/types/workflow.ts` since it's most critical for the workflow engine. Consider adding a validation layer that runs on workflow rule creation.
+
+## Auth Validation Gap
+
+**Middleware-only Session Check:**
+- Issue: `middleware.ts` (lines 75-82) only verifies a session cookie exists, does NOT check user role for `/admin` routes
+- Files: `middleware.ts`
+- Impact: Role-based access control is entirely at the API route level via `requireAdmin()` or `requirePermission()`. If a developer forgets to call `requireAuth()` or `requireAdmin()` in an API route, the middleware won't catch it. A mistake like returning user data without checking role is a privilege escalation. The comment on line 76 acknowledges this.
+- Fix approach: Add a helper function `enforceRole()` that is harder to forget than calling `requireAdmin()` early in a route handler. Better: make it so unauthenticated users can't even reach admin routes (redirect in middleware if no admin role detected — requires decoding the session token). Or add a lint rule that checks all API handlers for requireAuth calls. At minimum, add a test that verifies at least 10 admin API routes call requireAdmin() or requirePermission().
+
+## Missing Test Coverage
+
+**Limited Test Suite:**
+- Issue: Only `lib/services/analyzer/**`, `lib/services/rmm/**`, and `lib/services/b2/**` have unit tests; the rest of the codebase relies on TypeScript type-checking only
+- Files:
+ - Test files: `lib/services/analyzer/*.test.ts` (11 test files), `lib/services/rmm/*.test.ts` (2 test files), `lib/services/b2/*.test.ts` (1 test file), `lib/services/llm/*.test.ts` (2 test files)
+ - No tests for: sync services, webhook handlers, API routes, auth flows, workflow engine logic, integrations (Autotask, IT Glue, Veeam, etc.)
+- Impact: Refactoring core services like `entity-sync.ts` (1438 lines) or `webhook-service.ts` cannot be validated. Breaking changes in Autotask mapping logic, webhook handling, or sync schedules are only caught in staging. The NO CI note in `ARCHITECTURE.md` means no automated regression detection.
+- Fix approach: Start with high-impact areas: `lib/services/entity-sync.ts` and `lib/services/webhook-service.ts`. Add unit tests covering the main sync paths and webhook processing. Set a coverage target of 50% for critical services. Add pre-commit hook that runs `npm test` to prevent untested code from being committed.
+
+## Large Files with Complex Logic
+
+**Size and Complexity Hotspots:**
+- Issue: Several service files exceed 1000+ lines, indicating potential refactoring opportunities
+- Files:
+ - `lib/services/entity-sync.ts` (1438 lines) — Main Autotask sync, many entity types
+ - `lib/services/workflow-engine.ts` (954 lines) — Workflow execution and Autotask write-back
+ - `lib/services/ticket-digest-service.ts` (782 lines) — Daily digest generation
+ - `lib/services/sync-scheduler.ts` (760 lines) — Background task scheduling
+ - `lib/services/analyzer/asset-audit/data-builder.ts` (719 lines) — Evidence aggregation for IT Glue audits
+ - `lib/services/veeam-rpo-service.ts` (675 lines) — Veeam RPO logic
+ - `lib/services/mimecast-client.ts` (675 lines) — Mimecast API client
+ - `lib/services/itglue-sync-service.ts` (663 lines) — IT Glue integration
+- Impact: Large files are harder to reason about, test, and refactor. Bug fixes in `entity-sync.ts` might accidentally affect the sync of a different entity type if the logic isn't clearly separated. The workflow engine's 954 lines likely interleaves execution logic, Autotask API calls, and error handling.
+- Fix approach: Extract smaller modules from these files. For example, in `entity-sync.ts`, separate sync logic per entity into `sync/tickets.ts`, `sync/contacts.ts`, etc. In `ticket-digest-service.ts`, split template rendering into separate files. This is a gradual refactoring — start with `workflow-engine.ts` since it has the most potential for breaking bugs.
+
+## Unvalidated Input in API Routes
+
+**No Zod Validation Framework:**
+- Issue: API routes do not systematically validate request bodies or query parameters
+- Files: All `app/api/**/route.ts` files
+- Impact: Endpoints accept any JSON and only validate inputs "when it matters" (per CLAUDE.md). An admin form endpoint could silently ignore a malformed field instead of returning a 400 Bad Request. Developers must remember to manually validate each input; forgetting is easy.
+- Fix approach: Add a lightweight input validation helper (not necessarily Zod, but something). For example, a simple function like `validateInput(req.body, schema)` that checks required fields and types. Use it in high-risk endpoints: user creation, role assignment, workflow rules, integration settings. Start with `/api/admin/*` routes.
+
+## Known TODOs
+
+**Incomplete Implementations:**
+- Issue: Active TODOs left in production code
+- Files:
+ - `lib/services/sync-service.ts` (line 444) — "TODO: Implement graceful cancellation"
+ - `lib/services/workflow-steps/ai-troubleshooting.ts` (line 40) — "TODO: Implement createTicketNote in AutotaskClient if needed"
+ - `app/api/veeam/backup-status/route.ts` (line 51) — "TODO: compute from config items without matching workloads"
+- Impact: `sync-service.ts` sync cancellation is not implemented — if a sync is running and needs to be stopped (e.g., on pod termination), it will run to completion. This can delay graceful shutdown. The Veeam backup status compute is a stub returning 0.
+- Fix approach: Prioritize graceful cancellation in sync-service. For the others, either implement them or remove the TODOs if the current behavior is acceptable. Add a CI check that fails on new TODOs (optional but helpful).
+
+## Multi-Instance Sync Scheduler Risk
+
+**Sync Scheduler Not Instance-Safe:**
+- Issue: `lib/services/sync-scheduler.ts` uses node-cron but does NOT use row locking like the analyzer worker does
+- Files: `lib/services/sync-scheduler.ts`
+- Impact: In a multi-instance deployment, every instance will run every scheduled sync at the same time. If the sync scheduler is running on 3 replicas, Autotask gets 3 sync requests simultaneously, which wastes API quota and can cause race conditions on the Postgres side. The ARCHITECTURE.md (line 256) warns: "pin to one instance."
+- Fix approach: Either (a) force the sync scheduler to run on a single instance only by setting an env flag like `RUN_SYNC_SCHEDULER=1` and defaulting it to false on replicas, or (b) add a distributed lock (e.g., in Postgres with advisory locks) so only one instance's cron fires. Test multi-instance behavior in staging before deploying.
+
+## Analyzer Cost Ceiling Enforcement
+
+**Opacity in Stage 4 Skipping:**
+- Issue: Analyzer Stage 4 (Opus) is skipped if estimated cost exceeds $2.00 (line 138 in ARCHITECTURE.md), flagged for human review, but no clear UI/alert when this happens
+- Files: `lib/services/analyzer/pipeline.ts`, `lib/services/llm/pricing.ts`
+- Impact: An analysis with a cost ceiling hit is marked with a flag, but there's no alerting mechanism to tell admins that a ticket analysis was incomplete due to cost. The ticket analysis might be silently insufficient for the user.
+- Fix approach: Add a cost-ceiling alert row to `analyzer_analyses` or a separate cost-alert table. Expose this in the admin UI at `/admin/analyzer` with a filter for "cost-ceiling alerts." Log a structured event with `logger.warn()` so it's visible in centralized logs.
+
+## IT Glue Redaction Mandatory But Not Enforced
+
+**Redaction Bypass Risk:**
+- Issue: IT Glue search results MUST be redacted before being sent to an LLM (line 87-89 in ARCHITECTURE.md), but there's no type-system enforcement
+- Files: `lib/services/analyzer/itglue-search.ts` (redacted output), `lib/services/analyzer/` (callers)
+- Impact: A developer could accidentally import `itglue-client.ts` (the raw client) and pass results directly to an LLM prompt, exposing credentials or PII. The redaction is documented but optional in code.
+- Fix approach: Make the raw IT Glue client non-exported from its module, forcing all LLM-bound queries through the redacted search function. Add a type wrapper like `RedactedDocument` that is the only type accepted by LLM callers. Or add a pre-commit hook that scans for `itglue-client` imports in analyzer files and warns.
+
+## .env File Committed to Repo
+
+**Potential Secrets Exposure:**
+- Issue: `.env` file exists at `/opt/stacks/pulse/.env` and is NOT in `.gitignore`, but git ls-files shows no .env files tracked
+- Files: `/opt/stacks/pulse/.env`
+- Impact: Although the .env file is not currently tracked in git, it exists on the filesystem with potentially real configuration. The `.gitignore` pattern `.env*` should prevent accidental commits, but if someone edits `.gitignore` or adds `--force`, secrets could leak. This is a human-error risk.
+- Fix approach: Verify that no real secrets are in the committed .env file. Document that `.env*` is gitignored and point developers to `.env.example`. Add a pre-commit hook with `detect-secrets` or similar to catch hardcoded secrets. Rotate any keys/tokens that might have been exposed in historical runs.
+
+## Missing Graceful Shutdown for Workers
+
+**Worker Cleanup on Pod Termination:**
+- Issue: The analyzer worker (line 40 in ARCHITECTURE.md) resets stale in-flight jobs on boot, but there is no graceful shutdown handler for long-running operations
+- Files: `lib/services/analyzer/worker.ts`, `lib/services/sync-scheduler.ts`, `lib/services/rmm/worker.ts`
+- Impact: If a worker is in the middle of processing and the container is killed, that job is left in a partially-complete state (e.g., partial analyzer analysis, incomplete RMM execution). On pod restart, the worker resets jobs but may lose partial work.
+- Fix approach: Implement a `SIGTERM` handler that stops accepting new jobs, finishes in-flight work, then exits cleanly. Use `process.on('SIGTERM', async () => { ... })`. Set Kubernetes `terminationGracePeriodSeconds` to allow time for cleanup. Log completion of final jobs.
+
+## Pool Connection Leaks from Page Components
+
+**Server Component Pool Usage:**
+- Issue: Page components like `app/admin/users/[id]/page.tsx` and `app/admin/roles/[id]/page.tsx` create Pools without closing them, relying on garbage collection
+- Files: `app/admin/users/[id]/page.tsx`, `app/admin/roles/[id]/page.tsx`
+- Impact: Each page render creates a new Pool(). In development with fast reloads, pools accumulate. While they will eventually be garbage-collected, this is inefficient and can cause "too many connections" errors in development if tests run fast enough.
+- Fix approach: Use the postgresClient singleton instead (which is already lazy-initialized). This is the same fix as the broader pool instantiation issue above. Pages should import `postgresClient` from `lib/services/postgres-client.ts` rather than creating new Pools.
+
+## Performance: Large Page Sizes
+
+**Pagination Defaults Not Optimized:**
+- Issue: `qbo-client.ts` uses a pageSize of 1000 (line 187), which is high for API response sizes
+- Files: `lib/services/qbo-client.ts`
+- Impact: A single API call fetching 1000 QuickBooks objects can be slow and memory-intensive. If the endpoint returns large objects, response times spike.
+- Fix approach: Reduce default page size to 100-250 and let callers opt in for larger pages if needed. Measure API response times for the most common queries and set pageSize accordingly.
+
+## Idempotency Key Complexity
+
+**Provider-Scoped Content Hash Subtle:**
+- Issue: Analyzer content_hash is provider-scoped (per ARCHITECTURE.md), meaning the same ticket can have one Claude analysis and one OpenRouter analysis, but this is easy to miss
+- Files: `lib/services/analyzer/pipeline.ts` (lines 239, 253), `lib/services/analyzer/worker.ts` (lines 181, 314)
+- Impact: If a developer switches the default LLM provider from Anthropic to OpenRouter and re-runs an analysis with `force=false`, the code will treat it as a new analysis because the provider is part of the idempotency key. This is correct behavior but non-obvious and could confuse operators.
+- Fix approach: Add a comment at the storage point explaining that `provider` is part of the idempotency key. Document in CLAUDE.md or ARCHITECTURE.md that switching providers intentionally allows re-analysis. Add a debug endpoint that shows all analyses for a ticket grouped by provider.
+
+## Broken env Pattern in Datto RMM Sync
+
+**Optional API URL with Fallback:**
+- Issue: `lib/services/datto-rmm-sync-service.ts` (line 38) has a fallback hardcoded URL: `process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net'`
+- Files: `lib/services/datto-rmm-sync-service.ts`
+- Impact: The hardcoded fallback is correct (Datto's standard endpoint), but this pattern is inconsistent with other integrations which throw if env vars are missing. If an env var is unset by mistake, it silently uses the public endpoint instead of failing loudly.
+- Fix approach: Check if DATTO_RMM_API_URL should be required or optional. If optional, document why. If required, remove the fallback and let the code throw. Audit other clients for similar silent fallbacks.
+
+---
+
+*Concerns audit: 2026-05-03*
diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md
new file mode 100644
index 0000000..a947a2f
--- /dev/null
+++ b/.planning/codebase/CONVENTIONS.md
@@ -0,0 +1,263 @@
+# Coding Conventions
+
+**Analysis Date:** 2026-05-03
+
+## Naming Patterns
+
+**Files:**
+- kebab-case for all files and directories (e.g., `postgres-client.ts`, `invite-user-form.tsx`, `entity-sync.ts`)
+- Nested directories use kebab-case (e.g., `lib/services/analyzer/`, `components/admin/users/`)
+
+**Functions:**
+- camelCase for all functions (e.g., `getAutotaskClient()`, `transformCompany()`, `extractExplicitFromText()`)
+- Factory functions prefixed with `get` (e.g., `getAutotaskClient()`, `getDattoRmmClient()`)
+- Helper functions suffixed with descriptive intent (e.g., `relTime()`, `deriveSigningKey()`)
+- Private/internal functions prefixed with underscore: `_INTERNALS` objects expose internals for test access
+
+**Variables:**
+- camelCase for all variables (e.g., `isLoading`, `setData`, `ticketNumber`)
+- Constants in UPPER_SNAKE_CASE (e.g., `MAX_EXPLICIT_LINKS`, `TICKET_NUMBER_REGEX`)
+- Database column names are always snake_case (e.g., `company_name`, `is_active`, `created_at`)
+
+**Types:**
+- PascalCase for all type names (e.g., `ClassificationRule`, `WorkflowExecution`, `TicketData`)
+- Single-letter generics are acceptable (e.g., `queryEntity()`)
+- Union types as literal strings (e.g., `type RuleType = 'branch_routing' | 'ticket_type'`)
+
+**Components:**
+- PascalCase exported from kebab-case files (e.g., export `InviteUserForm` from `invite-user-form.tsx`)
+- Page components: `export default function ComponentName()` at end of file
+- Form components: follow `[Resource]Form` naming (e.g., `InviteUserForm`, `SignInForm`, `UserForm`)
+
+## Code Style
+
+**Formatting:**
+- TypeScript strict mode enabled (`"strict": true` in `tsconfig.json`)
+- No explicit formatter config (ESLint handles style)
+- Indentation: 2 spaces (inferred from existing code)
+
+**Linting:**
+- ESLint: `eslint.config.mjs` with Next.js config (`eslint-config-next/core-web-vitals`, `eslint-config-next/typescript`)
+- No additional custom rules beyond Next.js defaults
+- Type checking: `npx tsc --noEmit --pretty` (must pass before commit)
+
+## Import Organization
+
+**Order:**
+1. Node.js built-ins (e.g., `fs`, `path`)
+2. Third-party packages (e.g., `next/server`, `zod`, `vitest`)
+3. Type imports (e.g., `import type { ... } from '...'`)
+4. Local imports from `@/*` (using path alias)
+5. Local imports from `./` (relative, less common)
+
+**Path Aliases:**
+- Configured as `"@/*": ["./*"]` in `tsconfig.json`
+- Use `@/lib/...`, `@/components/...`, `@/app/...` always
+- Never use relative paths like `../../../` for imports
+
+**Example import block** (from `/opt/stacks/pulse/components/admin/users/invite-user-form.tsx`):
+```typescript
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { Loader2, Send } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+```
+
+## Error Handling
+
+**Pattern:**
+- All async functions use `try/catch` blocks
+- API routes: catch errors and return `NextResponse.json({ error, message }, { status })`
+- Standard status codes: `500` for runtime errors, `503` for missing/bad config, `401`/`403` from auth helpers
+- Error messages: include `error instanceof Error ? error.message : 'fallback message'`
+
+**Example from `/opt/stacks/pulse/app/api/companies/route.ts`:**
+```typescript
+export async function GET(request: NextRequest) {
+ try {
+ const result = await postgresClient.query('SELECT * FROM companies ...');
+ return NextResponse.json({ companies: result.rows.map(transformCompany) });
+ } catch (error) {
+ console.error('Error fetching companies from database:', error);
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to fetch companies' },
+ { status: 500 }
+ );
+ }
+}
+```
+
+**Client-side:** Use try/catch with `.json()` nulling:
+```typescript
+const [overviewRes, trendsRes] = await Promise.all([
+ fetch('/api/dashboard/overview', { cache: 'no-store' }),
+ fetch('/api/dashboard/trends', { cache: 'no-store' }),
+]);
+if (!overviewRes.ok) {
+ const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
+ throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
+}
+```
+
+## Logging
+
+**Framework:** Plain `console` (no structured logging library)
+
+**Patterns:**
+- `console.error()` for exceptions caught in try/catch (usually in API routes and services)
+- Include context: `console.error('Failed to fetch companies:', error)`
+- No `console.log()` for debugging (remove before commit per linter checks)
+
+## Comments
+
+**When to Comment:**
+- Explain *why*, not what (code shows the what)
+- Non-obvious logic or business rules
+- Performance-critical sections
+- Workarounds or hacks (mark with `// HACK:` or `// NOTE:`)
+
+**JSDoc/TSDoc:**
+- Used sparingly on complex functions
+- Example from `lib/services/analyzer/link-discovery.ts`:
+ ```typescript
+ /**
+ * Marks refs in a RELATED TICKETS: block as high confidence
+ */
+ export function extractExplicitFromText(text: string, source: string) { ... }
+ ```
+- Not required for simple getters/setters or obvious functions
+
+## Function Design
+
+**Size:**
+- Keep functions focused: one responsibility per function
+- Aim for <50 lines for page components, <30 for utilities
+- Complex operations broken into smaller helpers
+
+**Parameters:**
+- Prefer object parameters for >3 arguments
+- Don't use `any` — use specific types
+- Use `Partial` for optional object shapes
+
+**Return Values:**
+- Async functions always return `Promise` explicitly
+- Prefer `null` over `undefined` for missing values
+- Use discriminated unions for success/error returns in critical paths (see analyzer pipeline)
+
+## Module Design
+
+**Exports:**
+- Prefer `export` at declaration point rather than grouped re-exports
+- One main export per file (exception: barrel files in `components/ui/`)
+- Internal utilities prefixed with underscore: `_INTERNALS` object for test access
+
+**Barrel Files:**
+- `components/ui/index.ts` exports all shadcn primitives
+- `lib/types/` has domain-specific barrel files (e.g., `lib/types/workflow.ts`, `lib/types/autotask.ts`)
+- Avoid deep nesting — import from files, not directories unless barrel exists
+
+## Database Transformations
+
+**Pattern:** All columns are `snake_case` in database. API responses transform to `camelCase`.
+
+**Example from `/opt/stacks/pulse/app/api/companies/route.ts`:**
+```typescript
+function transformCompany(row: any) {
+ return {
+ id: Number(row.id),
+ companyName: row.company_name,
+ companyType: row.company_type,
+ isActive: row.is_active,
+ // ... all snake_case → camelCase
+ };
+}
+```
+
+No ORM is used — all transforms are manual per handler.
+
+## Shared Components & Libraries
+
+**UI Components:**
+- shadcn primitives live in `components/ui/`
+- Feature-specific components in sibling directories (e.g., `components/dashboard/`, `components/admin/`)
+- Icons: Always use `lucide-react` (e.g., `import { Loader2, Send } from 'lucide-react'`)
+
+**Tables:**
+- Use `@tanstack/react-table` via `components/admin/DataTable.tsx` wrapper
+- Example: ``
+
+**Modals:**
+- Use `components/admin/DetailModal.tsx` for entity details
+- Follows card + tabs pattern (formatted/raw)
+
+**Navigation:**
+- Use `components/navigation/app-navigation.tsx` (`NavigationMenu` from Radix)
+- Dropdowns prefer `@radix-ui/react-dropdown-menu` over submenus
+
+**Toasts:**
+- Use `sonner` library: `import { toast } from 'sonner'`
+- Patterns: `toast.success()`, `toast.error()`, `toast.info()`
+
+**Forms:**
+- Use `react-hook-form` + Zod for validation
+- Only in admin/auth forms — NOT in every page
+- Pattern: `useForm()` with `zodResolver()`, then `