`.
- - Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';`
- - Do NOT change any other layout.tsx content.
-
- Step C — Modify `components/theme-toggle.tsx` (THEME-04):
-
- - Rename or wrap the bare `setTheme` calls so each `DropdownMenuItem onClick` invokes a helper:
- ```tsx
- const writeTheme = (next: 'light' | 'dark' | 'system') => {
- setTheme(next);
- // Fire-and-forget server write. No await — the session bridge re-syncs on next session refresh anyway.
- fetch('/api/me/theme', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ theme: next }),
- }).catch(() => {
- // Silent fail on network error — the desktop affordance is best-effort.
- // The mobile profile Theme section is the explicit-error UX.
- });
- };
- ```
- - Replace the three `setTheme('light')`, `setTheme('dark')`, `setTheme('system')` calls with `writeTheme('light')`, `writeTheme('dark')`, `writeTheme('system')`.
- - Do NOT remove the `useTheme` import or the existing UI structure.
- - The `theme` value read from `useTheme()` stays in scope for the existing icon-rotation logic (no behavior change for the visual toggle).
-
-
-
-
diff --git a/.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md b/.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md
deleted file mode 100644
index ee8a746..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md
+++ /dev/null
@@ -1,143 +0,0 @@
----
-phase: 09-user-profile-preferences-new
-plan: "05"
-subsystem: mobile-profile-ui
-tags: [mobile, profile, channels, ntfy, teams, qrcode, theme, phase-9]
-
-requires:
- - phase: 09-04
- provides: "app/mobile/profile/page.tsx shell, ProfileChannelsSectionPlaceholder (stub replaced here)"
- - phase: 09-02
- provides: "GET /api/me/channels, PUT /api/me/channels/[type], DELETE /api/me/channels/[type], POST /api/me/channels/[type]/test, PUT /api/me/theme"
-provides:
- - "ProfileChannelsSection — real Channels Card with Teams + ntfy sub-sections, QR code, inline test results, advanced topic override with inline-error"
- - "ThemeSessionBridge — client effect that makes session.user.theme canonical by calling setTheme on mismatch"
- - "ThemeToggle write-through — every theme selection fires PUT /api/me/theme fire-and-forget"
- - "qrcode.react ^4.2.0 installed (node_modules + package.json + package-lock.json)"
-affects:
- - "Any future plan touching app/layout.tsx (ThemeSessionBridge is now a child of AuthProvider)"
- - "Any plan adding desktop theme affordances (ThemeToggle write-through pattern established)"
-
-tech-stack:
- added:
- - "qrcode.react ^4.2.0 — pure client-side SVG QR code generator; no network calls at render time"
- patterns:
- - "Inline 400 error pattern: state variable (teamsError / customTopicError) populated from response body.message || body.error; rendered as text-xs text-destructive below the relevant Input, cleared on next successful save"
- - "Mint-on-first-save ntfy: PUT /api/me/channels/ntfy with empty body {} mints a UUID-prefixed topic server-side; UI transitions from State A (no topic) to State B (topic + QR) on 200"
- - "Fire-and-forget server write with silent catch for desktop theme toggle (best-effort UX)"
- - "ThemeSessionBridge: useEffect watching session?.user + theme — calls setTheme only when serverTheme differs and passes 3-string allowlist guard"
-
-key-files:
- created:
- - components/mobile/profile/ProfileChannelsSection.tsx
- - components/mobile/profile/ThemeSessionBridge.tsx
- modified:
- - components/theme-toggle.tsx
- - app/layout.tsx
- - app/mobile/profile/page.tsx
- - package.json
- - package-lock.json
- deleted:
- - components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx
-
-key-decisions:
- - "qrcode.react installed via npm install during task (not just package.json edit) — lockfile regenerated so npm ci in Docker/CI is reproducible"
- - "ThemeSessionBridge mounted as first child of
in app/layout.tsx so useSession() has context from AuthProvider"
- - "ThemeToggle write-through is fire-and-forget with silent catch — desktop affordance is best-effort; mobile profile Theme section is the explicit-error UX (D-20)"
- - "Placeholder file deleted; page.tsx import updated from ProfileChannelsSectionPlaceholder to ProfileChannelsSection (same named export preserved in Plan 04 anticipation)"
-
-patterns-established:
- - "Inline error pattern: fetch PUT → non-2xx → setError(body.message || body.error || fallback) → render below Input; mirrors Teams URL and custom-topic ntfy error flows"
- - "ntfy State A / State B toggle: !ntfyTopic renders enable button; ntfyTopic renders QR + link + advanced disclosure + test/remove buttons"
-
-requirements-completed: [CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07, THEME-04]
-
-duration: 12min
-completed: "2026-05-10"
----
-
-# Phase 9 Plan 05: Channels UI + Theme Bridge Summary
-
-**Real ProfileChannelsSection with Teams/ntfy sub-sections + QR code + ThemeSessionBridge canonical sync — qrcode.react v4.2.0 installed, placeholder deleted, ThemeToggle writes through to /api/me/theme**
-
-## Performance
-
-- **Duration:** ~12 min
-- **Started:** 2026-05-10T12:00:00Z
-- **Completed:** 2026-05-10T12:12:00Z
-- **Tasks:** 2
-- **Files modified:** 7 (2 created, 3 modified, 1 deleted, 2 package files)
-
-## Accomplishments
-
-- Created `ProfileChannelsSection.tsx` — full Channels Card with Teams URL input + save/clear + inline 400 errors, and ntfy with mint-on-first-save, QR code via `QRCodeSVG`, subscribe link, advanced topic override with inline-error, test-now + remove buttons
-- Installed `qrcode.react ^4.2.0` via `npm install` (node_modules + package.json + package-lock.json all updated — lockfile reproducibility confirmed)
-- Deleted `ProfileChannelsSectionPlaceholder.tsx` and updated `app/mobile/profile/page.tsx` import to the real component
-- Created `ThemeSessionBridge.tsx` — null-rendering client component that calls `setTheme(session.user.theme)` when server theme differs from next-themes value, validated against 3-string allowlist
-- Mounted ` ` as first child of `` in `app/layout.tsx`
-- Updated `ThemeToggle` to call `writeTheme()` helper that calls `setTheme()` then fire-and-forget `PUT /api/me/theme`
-
-## Task Commits
-
-1. **Task 1: ProfileChannelsSection (Teams + ntfy + QR code) + qrcode.react install** — `1b7c453` (feat)
-2. **Task 2: Theme session bridge + ThemeToggle write-through** — `586c04a` (feat)
-
-## Files Created/Modified
-
-- `components/mobile/profile/ProfileChannelsSection.tsx` — Channels Card: Teams URL input + save/clear/inline-error, ntfy State A/B with QR code + advanced disclosure + inline-error; fetches `/api/me/channels` on mount
-- `components/mobile/profile/ThemeSessionBridge.tsx` — useEffect bridge comparing `session.user.theme` to `useTheme()`, calls `setTheme` on mismatch after 3-string allowlist guard; renders null
-- `components/theme-toggle.tsx` — `writeTheme()` helper wraps `setTheme()` + fire-and-forget `PUT /api/me/theme`
-- `app/layout.tsx` — ` ` mounted as first child of ``
-- `app/mobile/profile/page.tsx` — import line updated: `ProfileChannelsSectionPlaceholder` → `ProfileChannelsSection`
-- `package.json` — `"qrcode.react": "^4.2.0"` added to dependencies
-- `package-lock.json` — qrcode.react entries resolved and locked
-- `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` — DELETED (replaced by real component)
-
-## Decisions Made
-
-- `qrcode.react` installed via `npm install` (not just a package.json edit) so Docker/CI `npm ci` has a complete lockfile entry
-- `ThemeSessionBridge` placed inside `` so `useSession()` has access to the Better Auth context
-- ThemeToggle write-through is fire-and-forget with a silent `.catch()` — the desktop affordance is best-effort; the mobile profile Theme section is the explicit-error UX surface
-- Placeholder file deleted (not preserved); the real component exports the same named symbol `ProfileChannelsSection` so no cascading import changes were needed beyond page.tsx
-
-## Deviations from Plan
-
-None — plan executed exactly as written.
-
-## Known Stubs
-
-None — all stubs from Plan 04 (ProfileChannelsSectionPlaceholder) are resolved by this plan. The real ProfileChannelsSection is fully wired to `/api/me/channels`.
-
-## Threat Surface
-
-No new trust boundaries introduced beyond those documented in the plan's threat model (T-09-05-01 through T-09-05-08). All mitigations applied:
-
-- QR code: `qrcode.react` makes no network calls at render time — pure SVG client-side generation
-- Subscribe link: `rel="noopener noreferrer"` on `target="_blank"` anchor
-- aria-label on QR wrapper (`role="img" aria-label="Subscribe to {topic} on ntfy"`)
-- ThemeSessionBridge: 3-string allowlist guard before `setTheme` (drops any unexpected value silently)
-- Inline error strings: React auto-escapes string children; server-controlled messages rendered as text-xs
-
-## Manual Smoke Steps (LOW 14)
-
-Recorded per plan requirement:
-1. Navigate to `/mobile/profile` after sign-in — confirm no console errors during initial render (ProfileChannelsSection fetch + render)
-2. Confirm ` ` does not throw on first render (renders null, no visible output, no console errors)
-3. Teams URL with bad host → inline `text-xs text-destructive` shows server's `message`
-4. Enable mobile push → topic minted → QR visible → subscribe link opens in new tab
-5. Open "Edit advanced" → enter invalid topic → 400 → inline error shows under Input
-6. Switch desktop ThemeToggle → theme changes immediately → server write fires in background
-
-## Issues Encountered
-
-None.
-
-## Next Phase Readiness
-
-- Phase 9 is now complete: all 5 functional plans (01–05) landed. Plan 06 was the admin-surfaces plan (already committed in this wave at `7238c97`).
-- All mobile profile sections are live: Timezone, Theme, Notifications, Channels
-- ThemeSessionBridge is global (in app/layout.tsx) — theme sync works across all routes
-
----
-*Phase: 09-user-profile-preferences-new*
-*Completed: 2026-05-10*
diff --git a/.planning/phases/09-user-profile-preferences-new/09-06-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-06-PLAN.md
deleted file mode 100644
index ed8516b..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-06-PLAN.md
+++ /dev/null
@@ -1,523 +0,0 @@
----
-phase: 09-user-profile-preferences-new
-plan: 06
-type: execute
-wave: 3
-depends_on: [09-01, 09-02, 09-03]
-files_modified:
- - app/admin/workflow/channels/page.tsx
- - app/api/notification-channels/route.ts
- - app/api/notification-channels/[id]/route.ts
- - app/admin/workflow/event-keys/page.tsx
- - app/api/admin/notify-event-keys/route.ts
- - app/api/admin/notify-event-keys/[key]/route.ts
- - app/admin/workflow/executions/page.tsx
- - app/api/admin/pipeline-executions/route.ts
-autonomous: true
-requirements: [CHAN-06, SUB-01, ROUTE-07]
-
-must_haves:
- truths:
- - "Admins (role admin or super-admin) can read AND edit any user's personal notification channel via /admin/workflow/channels — including viewing the webhook_url / topic and toggling is_active and triggering test sends"
- - "/admin/workflow/channels has an Owner column that displays 'Global' for owner_user_id IS NULL or the owner's email otherwise, plus a filter widget that toggles between 'All', 'Global only', and 'Personal only'"
- - "/admin/workflow/event-keys exists as a real CRUD page for admins (list, create, edit, soft-toggle is_active, change sort_order, delete)"
- - "/admin/workflow/executions exists as a NEW admin page (not the legacy workflow_executions surface) that lists pipeline-engine executions across all pipelines with a 'Show only fallbacks' filter that filters to executions where any pipeline_execution_steps row has output_data ? 'user_route_fallback'"
- - "All admin routes use requireAdmin() — non-admin users get 403"
- - "Non-admin users can never read another user's personal channel via /api/notification-channels (the legacy endpoint hides personal rows for non-admins)"
- - "POST /api/notification-channels still accepts all four channel_type values (teams, telegram, ntfy, webhook) — the existing global-channel allowlist is unchanged"
- artifacts:
- - path: "app/admin/workflow/channels/page.tsx"
- provides: "Channels list with Owner column + filter; admin edit of personal channels"
- - path: "app/api/notification-channels/route.ts"
- provides: "Updated GET to require auth, scope visibility by role; POST stays admin-only"
- - path: "app/api/notification-channels/[id]/route.ts"
- provides: "Updated PUT/DELETE/GET to authorize per-row by ownership and role"
- - path: "app/admin/workflow/event-keys/page.tsx"
- provides: "New admin CRUD page for notify_event_keys"
- - path: "app/api/admin/notify-event-keys/route.ts"
- provides: "GET list / POST create event keys (admin-only)"
- - path: "app/api/admin/notify-event-keys/[key]/route.ts"
- provides: "PUT update / DELETE one event key by primary key"
- - path: "app/admin/workflow/executions/page.tsx"
- provides: "NEW admin page listing pipeline-engine executions with 'Show only fallbacks' filter (ROUTE-07)"
- - path: "app/api/admin/pipeline-executions/route.ts"
- provides: "GET list across all pipelines with optional fallbacks_only filter"
- key_links:
- - from: "/admin/workflow/channels"
- to: "notification_channels with owner_user_id JOIN user.email"
- via: "LEFT JOIN \"user\" ON owner_user_id = user.id; renders Owner column"
- pattern: "owner_user_id"
- - from: "/admin/workflow/event-keys"
- to: "notify_event_keys"
- via: "POST/PUT/DELETE via /api/admin/notify-event-keys"
- pattern: "notify_event_keys"
- - from: "/admin/workflow/executions"
- to: "pipeline_executions + pipeline_execution_steps.output_data->'user_route_fallback'"
- via: "GET /api/admin/pipeline-executions?fallbacks_only=1"
- pattern: "user_route_fallback"
----
-
-
-Land the admin surfaces required by Phase 9:
-
-1. **CHAN-06: Admin full-edit of personal channels.** `/admin/workflow/channels` gains
- an Owner column + filter. Admins can read/edit any user's personal Teams webhook URL
- or ntfy topic, toggle is_active, and trigger test sends. Non-admins still cannot
- see other users' personal channels via the legacy `/api/notification-channels` API.
-
-2. **SUB-01 admin CRUD: `/admin/workflow/event-keys`.** A new small admin page
- (D-13) for managing the `notify_event_keys` lookup. Backed by two new API routes.
-
-3. **ROUTE-07: New `/admin/workflow/executions` page with the fallback filter.**
- The plan checker's HIGH-3 issue is resolved here by building the filter on a
- NEW route at `/admin/workflow/executions` (not the legacy workflow-engine
- surface, which is `/admin/workflow/executions` in the legacy `workflow_executions`
- table — that table doesn't carry pipeline-engine fallback data anyway). This
- plan creates a fresh admin page and a fresh API route over the pipeline-engine
- `pipeline_executions` + `pipeline_execution_steps` tables, listing executions
- across ALL pipelines with the `user_route_fallback` filter. CONTEXT.md D-11
- specifically calls out `/admin/workflow/executions` as the surface for this
- filter — building it here honors the locked decision verbatim.
-
-Out of scope:
-- Backfilling existing notification_channels rows to set owner_user_id (they stay
- global / NULL).
-- Notifying users when their personal channel changes (deferred per CONTEXT
- "channel-rotation flow" deferred idea).
-
-Output: 3 modified existing files, 5 new files (3 new pages + 2 new API routes — the
-pipeline-executions API route is also new because we're scoping over all pipelines).
-
-This plan stays small per task by keeping API route handlers minimal (no new
-business logic — just CRUD + auth-gating) and keeping the SQL parameterized.
-
-
-
-@$HOME/.claude/get-shit-done/workflows/execute-plan.md
-@$HOME/.claude/get-shit-done/templates/summary.md
-
-
-
-@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
-@.planning/REQUIREMENTS.md
-@CLAUDE.md
-@app/admin/workflow/channels/page.tsx
-@app/api/notification-channels/route.ts
-@app/api/notification-channels/[id]/route.ts
-@app/admin/workflow/pipelines/[id]/page.tsx
-@app/api/pipelines/[id]/executions/route.ts
-@lib/auth-utils.ts
-@lib/services/personal-channels.ts
-
-
-
-
-notification_channels (post-Plan-01): id, name, channel_type, config, is_active, owner_user_id (NULL=global), created_at, updated_at
-
-notify_event_keys: key (PK), display_label, description, sort_order, is_active, created_at, updated_at
-
-pipeline_execution_steps.output_data is JSONB. After Plan 03, on user-route fallback the value contains:
- output_data->'user_route_fallback' = { reason: string, user_id?: string, channel_type: string, error?: string }
-
-
-requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
-
-
-- Reads from `/api/notification-channels` (no auth today — gap to close in Task 1)
-- Writes via `/api/notification-channels`, `/api/notification-channels/[id]`, `/api/notification-channels/[id]/test`
-- Uses Card + Switch + DataTable-less list rendering
-
-
-GET /api/admin/pipeline-executions?limit=N&fallbacks_only=1&pipeline_id=N → { data: Array }
-
-
-
-
-
-
- Task 1: Owner column + role-scoped reads on /admin/workflow/channels
- app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx
-
-
- - app/api/notification-channels/route.ts (current GET / POST — no auth today)
- - app/api/notification-channels/[id]/route.ts (current GET / PUT / DELETE — no auth today)
- - app/admin/workflow/channels/page.tsx (current rendering structure to extend)
- - lib/auth-utils.ts (requireAuth, requireAdmin)
- - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-07 admin full edit, CHAN-06)
-
-
-
- Step A — `app/api/notification-channels/route.ts` (GET + POST):
-
- Make this route auth-aware AND role-scoped. The existing implementation has no auth gate (security gap). Replace handlers with:
-
- GET:
- - Call `requireAuth()`. On error return error.
- - If `(session.user.role === 'admin' || session.user.role === 'super-admin')`:
- - Run a JOIN that returns owner email for personal channels:
- ```sql
- SELECT nc.*, u.email AS owner_email
- FROM notification_channels nc
- LEFT JOIN "user" u ON u.id = nc.owner_user_id
- ORDER BY nc.owner_user_id NULLS FIRST, nc.name
- ```
- - Optional `?owner=global|personal|all` filter (default `all`):
- - `global` → `WHERE owner_user_id IS NULL`
- - `personal` → `WHERE owner_user_id IS NOT NULL`
- - Else (non-admin):
- - Return only global rows (`WHERE owner_user_id IS NULL`) — non-admins see admin-managed channels for selection in pipelines but NOT other users' personal channels.
- - Response shape: `{ data: rows, total: rows.length }` (preserve existing shape).
-
- POST:
- - Call `requireAdmin()`. On error return error.
- - Existing validation logic preserved verbatim — the POST handler MUST continue to accept all four `channel_type` values: `teams`, `telegram`, `ntfy`, `webhook` (LOW 12 from plan checker — explicit acceptance criterion).
- - INSERT statement adds `owner_user_id` to the column list, accepting `body.owner_user_id ?? null`. Default behavior: rows created via this admin endpoint are GLOBAL (owner_user_id NULL).
-
- Step B — `app/api/notification-channels/[id]/route.ts` (GET / PUT / DELETE):
-
- Each handler:
- - `requireAuth()` first.
- - Read the row (`SELECT * FROM notification_channels WHERE id = $1`).
- - Compute `isOwner = row.owner_user_id === session.user.id`.
- - Compute `isAdmin = session.user.role === 'admin' || session.user.role === 'super-admin'`.
- - For GET / PUT / DELETE:
- - If row is global (`owner_user_id IS NULL`) → require `isAdmin` else 403.
- - If row is personal → require `isOwner OR isAdmin` else 403 (CHAN-06: admins have full read+edit).
- - Existing UPDATE / DELETE logic preserved verbatim under the gate.
-
- Step C — `app/admin/workflow/channels/page.tsx`:
-
- - Page top: add a filter Select (using `@/components/ui/select`) labeled "Show:" with options "All" (default), "Global only", "Personal only". State `ownerFilter: 'all' | 'global' | 'personal'`. Reload list when it changes by passing `?owner=...` to `/api/notification-channels`.
- - Add an "Owner" badge to each channel row card. Render BEFORE the existing channel-type Badge:
- - When `channel.owner_user_id == null`: `Global `
- - Else: `Personal: {channel.owner_email ?? channel.owner_user_id} ` (the `owner_email` field comes from the JOIN added in Step A).
- - The existing edit/delete/test buttons stay on every row regardless of ownership — this is the admin surface (D-07: full edit).
- - Add a small disclaimer above the channel list when `ownerFilter !== 'global'`: `Personal channels contain user-supplied webhook URLs — handle with care.
` (operational hygiene).
-
-
-
- grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts && grep -q "owner_user_id" app/api/notification-channels/route.ts && grep -q "requireAuth" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_user_id" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_email\|owner_user_id" app/admin/workflow/channels/page.tsx && grep -q "Global only\|owner=global\|Personal only" app/admin/workflow/channels/page.tsx && grep -q "teams\|telegram\|ntfy\|webhook" app/api/notification-channels/route.ts && npx tsc --noEmit --pretty 2>&1 | head
-
-
-
- - `app/api/notification-channels/route.ts` GET handler calls `requireAuth()` (it does NOT today)
- - GET handler returns only global rows (`WHERE owner_user_id IS NULL`) for non-admin sessions
- - GET handler for admin sessions runs `LEFT JOIN "user" u ON u.id = nc.owner_user_id` and selects `u.email AS owner_email`
- - GET handler accepts an `owner` query parameter with values `global` / `personal` / `all`
- - POST handler calls `requireAdmin()` (it does NOT today)
- - **LOW 12 (explicit acceptance):** POST handler still accepts all four `channel_type` values: `'teams'`, `'telegram'`, `'ntfy'`, `'webhook'` (the global-channel allowlist is unchanged from today)
- - `app/api/notification-channels/[id]/route.ts` PUT and DELETE handlers each call `requireAuth()` and authorize via `(isAdmin || isOwner)` predicate against `row.owner_user_id`
- - `app/admin/workflow/channels/page.tsx` renders an Owner badge on each channel row that displays `Global` for null owner or `Personal: {email}` otherwise
- - `app/admin/workflow/channels/page.tsx` has a Select filter with the three values (`all`, `global`, `personal`)
- - Selecting the filter updates the fetch URL with `?owner=`
- - `npx tsc --noEmit --pretty` reports no errors
-
-
-
- Admin Channels page handles personal-vs-global rows; admins see full data and can edit any row; non-admins (e.g., a pipeline operator without admin role) only see global rows. POST allowlist for `channel_type` remains all four values.
-
-
-
-
- Task 2: /admin/workflow/event-keys CRUD page + API
- app/admin/workflow/event-keys/page.tsx, app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts
-
-
- - app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft back link + container layout)
- - lib/auth-utils.ts (requireAdmin signature)
- - lib/types/pipeline.ts (NotifyEventKey type from Plan 01)
- - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-13 lookup table — admin manages, not a gate)
-
-
-
- Step A — `app/api/admin/notify-event-keys/route.ts`:
-
- GET:
- - `requireAdmin()` guard.
- - `SELECT key, display_label, description, sort_order, is_active, created_at, updated_at FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
- - Return `{ data: rows.map(camelCase) }`.
-
- POST:
- - `requireAdmin()` guard.
- - Body: `{ key, display_label, description?, sort_order?, is_active? }`
- - Validate `key` is non-empty string ≤ 128 chars matching `/^[a-z][a-z0-9_]*$/i` (event key naming).
- - Validate `display_label` is non-empty string ≤ 200.
- - INSERT and return the new row. Use `ON CONFLICT (key) DO NOTHING` then re-SELECT — return 409 if key already existed.
-
- Step B — `app/api/admin/notify-event-keys/[key]/route.ts`:
-
- PUT:
- - `requireAdmin()` guard.
- - `params.key` is the row to update.
- - Body fields: `display_label?`, `description?`, `sort_order?`, `is_active?`.
- - `UPDATE notify_event_keys SET display_label = COALESCE($1, display_label), description = COALESCE($2, description), sort_order = COALESCE($3, sort_order), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE key = $5 RETURNING *`
- - 404 if no row.
-
- DELETE:
- - `requireAdmin()` guard.
- - `DELETE FROM notify_event_keys WHERE key = $1 RETURNING key`
- - 404 if no row. Return `{ deleted: true, key }`.
-
- Step C — `app/admin/workflow/event-keys/page.tsx`:
-
- - `'use client';` page modeled on `/admin/workflow/channels/page.tsx`.
- - Header: ArrowLeft back link to `/admin/workflow`, page title "Event Keys".
- - Body: a list of Cards or rows displaying each event key with its `display_label`, `description`, `sort_order`, `is_active` Switch, and Edit / Delete buttons.
- - "+ New event key" button opens an inline form (Input for `key`, Input for `display_label`, Input for `description`, NumericInput for `sort_order`).
- - All writes via `/api/admin/notify-event-keys/...` endpoints.
- - Render error toast via `sonner` on non-2xx; success toast on save/delete.
-
-
-
- test -f app/admin/workflow/event-keys/page.tsx && test -f app/api/admin/notify-event-keys/route.ts && test -f "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "requireAdmin" app/api/admin/notify-event-keys/route.ts && grep -q "requireAdmin" "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "notify_event_keys" app/api/admin/notify-event-keys/route.ts && npx tsc --noEmit --pretty 2>&1 | head
-
-
-
- - `app/admin/workflow/event-keys/page.tsx` exists, starts with `'use client';`
- - File renders a list of event keys and has a "+ New event key" affordance
- - File issues writes to `/api/admin/notify-event-keys` (POST, PUT, DELETE on the by-key sub-route)
- - `app/api/admin/notify-event-keys/route.ts` exports GET and POST; both call `requireAdmin()`
- - GET runs `SELECT ... FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
- - POST validates `key` matches `/^[a-z][a-z0-9_]*$/i`
- - `app/api/admin/notify-event-keys/[key]/route.ts` exports PUT and DELETE; both call `requireAdmin()`
- - PUT uses `UPDATE notify_event_keys SET ... WHERE key = $5` (or equivalent param index)
- - `npx tsc --noEmit --pretty` reports no errors
-
-
-
- Admins have a real CRUD page for the event-key lookup (D-13) — D-13 surface lives at the URL CONTEXT.md specifies.
-
-
-
-
- Task 3: NEW /admin/workflow/executions page + API for ROUTE-07 fallback filter
- app/admin/workflow/executions/page.tsx, app/api/admin/pipeline-executions/route.ts
-
-
- - app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft + container)
- - app/admin/workflow/pipelines/[id]/page.tsx (current per-pipeline executions panel — borrow rendering ideas; DO NOT modify this file in this plan)
- - app/api/pipelines/[id]/executions/route.ts (per-pipeline executions endpoint — borrow JSONB predicate idea; DO NOT modify in this plan)
- - lib/types/pipeline.ts (PipelineExecution type if exported; otherwise inline)
- - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-11 ROUTE-07 surface = `/admin/workflow/executions`, locked decision)
- - .planning/REQUIREMENTS.md (ROUTE-07 — verbatim text says `/admin/workflow/executions`)
-
-
-
- **HIGH 3 (plan checker) resolution:** CONTEXT.md and REQUIREMENTS.md both name
- `/admin/workflow/executions` as the surface. The legacy `workflow_executions`
- table doesn't carry pipeline-engine `user_route_fallback` data. Build a NEW
- page at `/admin/workflow/executions` over the pipeline-engine tables — that
- is where the data lives and the URL the user locked in. We are NOT touching
- the legacy `workflow_executions` table or any existing admin route.
-
- Step A — Create `app/api/admin/pipeline-executions/route.ts`:
-
- ```typescript
- // GET /api/admin/pipeline-executions?limit=50&fallbacks_only=1&pipeline_id=NN
- //
- // Lists pipeline-engine executions across ALL pipelines (or one pipeline
- // when pipeline_id is provided). Joins a `has_fallback` boolean computed
- // from pipeline_execution_steps.output_data ? 'user_route_fallback'.
- // ROUTE-07 / D-11 — admin filter for user_route_fallback events.
-
- import { NextRequest, NextResponse } from 'next/server';
- import { requireAdmin } from '@/lib/auth-utils';
- import { postgresClient } from '@/lib/services/postgres-client';
-
- export async function GET(req: NextRequest): Promise {
- const { error } = await requireAdmin();
- if (error) return error;
-
- const { searchParams } = new URL(req.url);
- const fallbacksOnly = searchParams.get('fallbacks_only') === '1';
- const pipelineIdRaw = searchParams.get('pipeline_id');
- const pipelineId = pipelineIdRaw && /^\d+$/.test(pipelineIdRaw)
- ? Number(pipelineIdRaw) : null;
- const limitRaw = searchParams.get('limit');
- const limit = limitRaw && /^\d+$/.test(limitRaw)
- ? Math.min(Number(limitRaw), 500) : 100;
-
- // HIGH 4 (plan checker) — choose ONE of two complete parameterized SQL
- // strings; never use a SELECT-list alias inside its own WHERE clause
- // (PostgreSQL rejects that).
- try {
- const params: any[] = [];
- let sql: string;
-
- if (fallbacksOnly && pipelineId !== null) {
- params.push(pipelineId, limit);
- sql = `
- SELECT pe.*, true AS has_fallback
- FROM pipeline_executions pe
- WHERE pe.pipeline_id = $1
- AND EXISTS (
- SELECT 1 FROM pipeline_execution_steps pes
- WHERE pes.execution_id = pe.id
- AND pes.output_data ? 'user_route_fallback'
- )
- ORDER BY pe.created_at DESC
- LIMIT $2
- `;
- } else if (fallbacksOnly) {
- params.push(limit);
- sql = `
- SELECT pe.*, true AS has_fallback
- FROM pipeline_executions pe
- WHERE EXISTS (
- SELECT 1 FROM pipeline_execution_steps pes
- WHERE pes.execution_id = pe.id
- AND pes.output_data ? 'user_route_fallback'
- )
- ORDER BY pe.created_at DESC
- LIMIT $1
- `;
- } else if (pipelineId !== null) {
- params.push(pipelineId, limit);
- sql = `
- SELECT pe.*,
- EXISTS (
- SELECT 1 FROM pipeline_execution_steps pes
- WHERE pes.execution_id = pe.id
- AND pes.output_data ? 'user_route_fallback'
- ) AS has_fallback
- FROM pipeline_executions pe
- WHERE pe.pipeline_id = $1
- ORDER BY pe.created_at DESC
- LIMIT $2
- `;
- } else {
- params.push(limit);
- sql = `
- SELECT pe.*,
- EXISTS (
- SELECT 1 FROM pipeline_execution_steps pes
- WHERE pes.execution_id = pe.id
- AND pes.output_data ? 'user_route_fallback'
- ) AS has_fallback
- FROM pipeline_executions pe
- ORDER BY pe.created_at DESC
- LIMIT $1
- `;
- }
-
- const result = await postgresClient.query(sql, params);
- return NextResponse.json({ data: result.rows });
- } catch (e) {
- console.error('GET /api/admin/pipeline-executions failed:', e);
- return NextResponse.json(
- { error: 'Failed to read executions', message: e instanceof Error ? e.message : 'unknown' },
- { status: 500 },
- );
- }
- }
- ```
-
- Critical: NEVER concatenate a column alias (`has_fallback`) into a WHERE
- clause on the same SELECT. PostgreSQL rejects `WHERE has_fallback` because
- aliases aren't visible in WHERE — only in ORDER BY, GROUP BY, and the outer
- layer of a subquery. The pseudo-SQL in the original plan had this bug
- (`${fallbacks_only ? 'AND has_fallback' : ''}`); the four parameterized
- strings above replace it with the EXISTS predicate inlined into WHERE.
-
- Step B — Create `app/admin/workflow/executions/page.tsx` (`'use client'`):
-
- - Header: ArrowLeft back link to `/admin/workflow`, page title "Pipeline Executions".
- - Above the list: a row with ` ` labeled "Show only fallbacks" (use `@/components/ui/switch` and ``). State `fallbacksOnly: boolean`.
- - Optional pipeline filter: a `` listing existing pipelines (fetch `/api/pipelines` if a list endpoint exists; otherwise leave the field as a free-text "Pipeline ID" Input). Default = `all`.
- - List: each row renders `id`, `pipeline_id`, `status`, `started_at`, `duration_ms`, and a `fallback ` when `has_fallback === true`.
- - On toggle change OR pipeline-filter change: re-fetch `/api/admin/pipeline-executions?fallbacks_only=1&pipeline_id=...&limit=100` and re-render.
- - Empty state: `No executions match the current filter.
`
- - Loading state: render a small Skeleton.
- - Each row clickable: ` ` so the admin can drill into the per-pipeline detail page (which already exists).
-
-
-
- test -f app/admin/workflow/executions/page.tsx && test -f app/api/admin/pipeline-executions/route.ts && grep -q "requireAdmin" app/api/admin/pipeline-executions/route.ts && grep -q "user_route_fallback" app/api/admin/pipeline-executions/route.ts && grep -q "fallbacks_only" app/api/admin/pipeline-executions/route.ts && grep -q "has_fallback" app/api/admin/pipeline-executions/route.ts && ! grep -q "AND has_fallback" app/api/admin/pipeline-executions/route.ts && grep -q "Show only fallbacks" app/admin/workflow/executions/page.tsx && grep -q "fallbacks_only=1\|fallbacks_only=" app/admin/workflow/executions/page.tsx && grep -q "fallback" app/admin/workflow/executions/page.tsx && npx tsc --noEmit --pretty 2>&1 | head
-
-
-
- - `app/admin/workflow/executions/page.tsx` exists at the URL CONTEXT.md / REQUIREMENTS.md locks (`/admin/workflow/executions`)
- - File starts with `'use client';`
- - File contains a Switch labeled `Show only fallbacks`
- - File appends `fallbacks_only=1` to the executions fetch URL when the toggle is ON
- - File renders a `fallback ` (or equivalent) on rows where `has_fallback === true`
- - `app/api/admin/pipeline-executions/route.ts` exports `GET` and calls `requireAdmin()`
- - The route handler accepts `fallbacks_only`, `pipeline_id`, and `limit` query parameters
- - The route handler implements **four** complete parameterized SQL strings (selected by the boolean flags); the `has_fallback` SELECT-list alias is NEVER referenced inside the same SELECT's WHERE clause (HIGH 4 from plan checker — verified by `! grep -q "AND has_fallback" route.ts`)
- - Each SQL string contains the JSONB containment predicate `output_data ? 'user_route_fallback'`
- - Each SQL string is parameterized: `pipeline_id` is `$1` or `$2`; `limit` always last; pipeline_id passes a `/^\d+$/` regex coercion before binding
- - Per-row response contains `has_fallback: boolean` (either as constant `true` in the fallbacks-only branches or as an EXISTS subquery in the unfiltered branches)
- - This plan does NOT modify `app/admin/workflow/pipelines/[id]/page.tsx` or `app/api/pipelines/[id]/executions/route.ts` (the new admin surface is fully fresh — preserves the locked URL without disturbing existing pipeline-detail surfaces)
- - `npx tsc --noEmit --pretty` reports no errors
-
-
-
- The ROUTE-07 fallback filter exists at the locked URL `/admin/workflow/executions`, sourced from the pipeline-engine tables (the only tables that carry `user_route_fallback`). The SQL avoids the alias-in-WHERE bug by selecting one of four complete parameterized strings.
-
-
-
-
-
-
-
-## Trust Boundaries
-
-| Boundary | Description |
-|----------|-------------|
-| Browser → /admin/* | Admin role required; non-admins must get 403 |
-| Browser → /api/notification-channels | Previously unauthenticated — closing the gap |
-| Browser → /api/admin/notify-event-keys | New surface; admin-only |
-| Browser → /api/admin/pipeline-executions | New surface; admin-only |
-
-## STRIDE Threat Register (ASVS L1)
-
-| Threat ID | Category | Component | Disposition | Mitigation Plan |
-|-----------|----------|-----------|-------------|-----------------|
-| T-09-06-01 | Information Disclosure | `/api/notification-channels` GET | mitigate | Adds `requireAuth()` (was unauthenticated). Non-admin sessions are scoped to global rows only via `WHERE owner_user_id IS NULL`. Admins see all rows. Closes a pre-existing gap |
-| T-09-06-02 | Elevation of Privilege | `/api/notification-channels/[id]` PUT/DELETE | mitigate | Per-row authorization: `isAdmin || isOwner`. A non-admin can edit only their own personal channel; the legacy admin path for global channels still requires admin |
-| T-09-06-03 | Information Disclosure | Admin Owner-column rendering | accept | Webhook URLs and ntfy topics ARE shown in the admin UI — D-07 explicitly accepts this trade-off (admins can read user secrets) to enable onboarding/offboarding fixes. Mitigation: small inline disclaimer above the personal-channel list |
-| T-09-06-04 | Tampering | `/api/admin/notify-event-keys` POST | mitigate | `requireAdmin()` guard + `key` regex `^[a-z][a-z0-9_]*$/i` + length limits. `ON CONFLICT (key) DO NOTHING` prevents accidental overwrite of an existing key — UI must use PUT for updates |
-| T-09-06-05 | Elevation of Privilege | `/admin/workflow/event-keys` page | mitigate | Page issues all writes through the gated API routes; even if a non-admin reaches the page URL directly, the API returns 403. Page does not embed admin-only secrets in the rendered HTML beyond what the API would return |
-| T-09-06-06 | SQL Injection | executions endpoint | mitigate | All user input is parameterized: `pipeline_id` validated against `/^\d+$/` before binding to `$1`, `limit` validated against `/^\d+$/` and capped at 500. `fallbacks_only` is a boolean (`searchParams.get('fallbacks_only') === '1'`) used to choose which of FOUR complete SQL strings to execute — never concatenated. The earlier alias-in-WHERE bug (HIGH 4) is fixed by inlining the EXISTS predicate into WHERE in the fallbacks-only branches |
-| T-09-06-07 | Information Disclosure | per-row `has_fallback` flag | accept | The `EXISTS()` subquery is admin-only context (page is `/admin/workflow/executions`, gated by `requireAdmin()`). Reveals only that a fallback occurred, not the personal user_id (which is in the step output_data and only loaded on row click via the existing pipeline-detail page) |
-| T-09-06-08 | Tampering | LOW 12 channel_type allowlist | mitigate | POST handler retains the existing four-value `channel_type` allowlist (`teams`, `telegram`, `ntfy`, `webhook`); acceptance criteria explicitly verify it is unchanged |
-
-No `high` severity unmitigated. ASVS L1 satisfied: V4.1.1 (per-resource authorization), V4.2.2 (data minimization for non-admins), V5.1.3 (input validation on the event key), V12.1.1 (parameterized SQL — validated by absence of alias-in-WHERE).
-
-
-
-
-- All seven admin/API files compile under `npx tsc --noEmit --pretty`.
-- Hitting `/api/notification-channels` without a session returns 401.
-- Hitting `/api/notification-channels` as a non-admin user returns only global rows.
-- Hitting `/api/notification-channels/[id]` PUT for someone else's personal row as a non-admin returns 403.
-- Hitting `/api/admin/notify-event-keys` as a non-admin returns 403.
-- Hitting `/api/admin/pipeline-executions` as a non-admin returns 403.
-- Hitting `/api/admin/pipeline-executions?fallbacks_only=1` as an admin returns ONLY rows where some step output_data contains `user_route_fallback`.
-- Hitting `/api/admin/pipeline-executions` as an admin returns ALL recent rows, each with `has_fallback: boolean`.
-- LOW 12: POSTing `{ name: 'test', channel_type: 'webhook', config: {} }` to `/api/notification-channels` as admin still creates a webhook-type global channel (no behavior change).
-- The new admin executions page renders at `/admin/workflow/executions` and the toggle filter works.
-
-
-
-1. `/admin/workflow/channels` shows Owner column + filter; admins can edit any personal row; non-admins see only global rows.
-2. `/admin/workflow/event-keys` exists as a real CRUD page; reachable from `/admin/workflow` (the executor can either add a link there or document the URL in the SUMMARY for a future small wire-up).
-3. `/admin/workflow/executions` exists as a NEW admin page over the pipeline-engine tables, with a "Show only fallbacks" toggle and per-row fallback badge — at the URL CONTEXT.md / REQUIREMENTS.md lock.
-4. All admin API routes are gated by `requireAdmin()`; the legacy notification-channels API now uses `requireAuth()` + per-row authorization.
-5. The pipeline-executions SQL handler uses **four** complete parameterized strings (no alias-in-WHERE bug).
-6. POST `/api/notification-channels` channel_type allowlist is unchanged — all four values still accepted.
-7. `npx tsc --noEmit --pretty` exits 0.
-
-
-
-After completion, create `.planning/phases/09-user-profile-preferences-new/09-06-SUMMARY.md` documenting:
-- The Owner column rendering rule (Global vs Personal: {email})
-- The owner-filter URL parameter values supported by `/api/notification-channels`
-- The new event-keys CRUD page URL and the regex used to validate event keys
-- The exact JSONB predicate used by the new pipeline-executions endpoint (`output_data ? 'user_route_fallback'`)
-- The `has_fallback` per-row flag on the executions response
-- Confirmation: the ROUTE-07 filter lives at the LOCKED URL `/admin/workflow/executions` — no silent rerouting
-- Confirmation: the executions route uses FOUR complete parameterized SQL strings; alias-in-WHERE bug is gone
-- Confirmation: POST `/api/notification-channels` channel_type allowlist (`teams`, `telegram`, `ntfy`, `webhook`) is unchanged
-- Whether a link to `/admin/workflow/event-keys` and `/admin/workflow/executions` was added on `/admin/workflow` (small wire-up — note in SUMMARY if deferred)
-
diff --git a/.planning/phases/09-user-profile-preferences-new/09-06-SUMMARY.md b/.planning/phases/09-user-profile-preferences-new/09-06-SUMMARY.md
deleted file mode 100644
index 2abb691..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-06-SUMMARY.md
+++ /dev/null
@@ -1,177 +0,0 @@
----
-phase: 09-user-profile-preferences-new
-plan: "06"
-subsystem: admin-surfaces
-tags: [admin, notification-channels, event-keys, pipeline-executions, auth, phase-9]
-dependency_graph:
- requires:
- - "notification_channels.owner_user_id column (Plan 01 — migration 085)"
- - "notify_event_keys table (Plan 01 — migration 086)"
- - "pipeline_executions + pipeline_execution_steps tables (pre-existing pipeline engine)"
- provides:
- - "GET /api/notification-channels: requireAuth(), role-scoped, owner_email JOIN, ?owner=global|personal|all"
- - "POST /api/notification-channels: requireAdmin(), owner_user_id column accepted"
- - "GET/PUT/DELETE /api/notification-channels/[id]: requireAuth() + per-row (isAdmin || isOwner) authorization"
- - "Owner badge on /admin/workflow/channels (Global vs Personal: {email}) + Show filter"
- - "GET/POST /api/admin/notify-event-keys: admin-only list + create with key regex"
- - "PUT/DELETE /api/admin/notify-event-keys/[key]: admin-only update + delete"
- - "/admin/workflow/event-keys: full CRUD page for notify_event_keys"
- - "GET /api/admin/pipeline-executions: admin-only, fallbacks_only/pipeline_id/limit params, has_fallback boolean"
- - "/admin/workflow/executions: NEW admin page at ROUTE-07 locked URL with fallback filter"
- affects:
- - "Plans 03/04/05 — personal-channels service now has gated backing API"
- - "Any non-admin code calling /api/notification-channels — now requires auth (previously unauthenticated)"
-tech_stack:
- added: []
- patterns:
- - "Per-row authorization: isAdmin || isOwner predicate against owner_user_id"
- - "Role-scoped GET: admin sees all + LEFT JOIN owner email; non-admin sees WHERE owner_user_id IS NULL"
- - "Four complete parameterized SQL strings for pipeline-executions — no alias-in-WHERE (HIGH 4 fix)"
- - "EXISTS subquery inlined in WHERE for fallback filter — avoids PostgreSQL alias-in-WHERE rejection"
- - "URLSearchParams.set() for building query strings in client components"
-key_files:
- created:
- - app/api/admin/notify-event-keys/route.ts
- - app/api/admin/notify-event-keys/[key]/route.ts
- - app/admin/workflow/event-keys/page.tsx
- - app/api/admin/pipeline-executions/route.ts
- - app/admin/workflow/executions/page.tsx
- modified:
- - app/api/notification-channels/route.ts
- - app/api/notification-channels/[id]/route.ts
- - app/admin/workflow/channels/page.tsx
-decisions:
- - "Per-row authorization uses checkAccess() helper that treats owner_user_id=null (global) as admin-only; personal rows allow isOwner OR isAdmin"
- - "GET /api/notification-channels now requires requireAuth() — pre-existing security gap closed; non-admins see only global rows"
- - "Four parameterized SQL strings for pipeline-executions chosen over dynamic WHERE building — eliminates alias-in-WHERE bug from original plan pseudo-SQL"
- - "Conflict detection on notify_event_keys POST: ON CONFLICT DO NOTHING + timestamp-based check (created_at within 2s = just inserted)"
- - "/admin/workflow/event-keys and /admin/workflow/executions not linked from /admin/workflow index page — noted as deferred wire-up (see Known Stubs)"
-metrics:
- duration_minutes: 20
- completed_date: "2026-05-10"
- tasks_completed: 3
- files_created: 5
- files_modified: 3
----
-
-# Phase 9 Plan 06: Admin Surfaces Summary
-
-One-liner: Three admin surfaces land — notification-channels gets owner column + role-scoped auth (closing a pre-existing unauth gap), a new event-keys CRUD page backed by two new admin API routes, and a new pipeline-executions page at the ROUTE-07 locked URL with a fallback filter using four safe parameterized SQL strings.
-
-## Tasks Completed
-
-| Task | Name | Commit | Files |
-|------|------|--------|-------|
-| 1 | Owner column + role-scoped reads on /admin/workflow/channels | 47cab78 | app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx |
-| 2 | /admin/workflow/event-keys CRUD page + API | 7f4ffa0 | app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts, app/admin/workflow/event-keys/page.tsx |
-| 3 | NEW /admin/workflow/executions page + API for ROUTE-07 fallback filter | 23a8c7c | app/api/admin/pipeline-executions/route.ts, app/admin/workflow/executions/page.tsx |
-
-## What Was Built
-
-### Owner Column Rendering Rule
-
-- `channel.owner_user_id == null` → `Global `
-- `channel.owner_user_id != null` → `Personal: {owner_email ?? owner_user_id} `
-- The `owner_email` field comes from a `LEFT JOIN "user" u ON u.id = nc.owner_user_id` in the admin GET query.
-- Owner badge renders BEFORE the channel-type badge in each channel row card.
-
-### Owner-filter URL Parameter on /api/notification-channels
-
-GET accepts `?owner=global|personal|all` (default: `all` for admin sessions).
-
-- `global` → `WHERE nc.owner_user_id IS NULL`
-- `personal` → `WHERE nc.owner_user_id IS NOT NULL`
-- `all` → no WHERE clause on ownership (full JOIN result)
-
-Non-admin sessions always get global-only rows regardless of the `owner` parameter.
-
-### Event Keys CRUD Page
-
-- URL: `/admin/workflow/event-keys`
-- Key regex for POST validation: `/^[a-z][a-z0-9_]*$/i` (max 128 chars)
-- `display_label` max 200 chars, required
-- POST uses `ON CONFLICT (key) DO NOTHING` + timestamp-based duplicate detection → 409 if key pre-existed
-- Inline edit on each row; `is_active` toggles via PUT with `COALESCE($4, is_active)`
-- PUT uses: `UPDATE notify_event_keys SET display_label = COALESCE($1, display_label), description = COALESCE($2, description), sort_order = COALESCE($3, sort_order), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE key = $5`
-
-### Pipeline Executions API — JSONB Predicate
-
-JSONB containment predicate: `pes.output_data ? 'user_route_fallback'`
-
-Used in all four SQL branches as an EXISTS subquery inlined in WHERE — never as a SELECT-list alias referenced in WHERE.
-
-### has_fallback Per-Row Flag
-
-- `fallbacksOnly=true` branches: constant `true AS has_fallback`
-- `fallbacksOnly=false` branches: `EXISTS (SELECT 1 FROM pipeline_execution_steps pes WHERE pes.execution_id = pe.id AND pes.output_data ? 'user_route_fallback') AS has_fallback`
-- The page renders `fallback ` on rows where `has_fallback === true`
-
-### ROUTE-07 Confirmation
-
-The filter at `/admin/workflow/executions` is the URL locked by both CONTEXT.md (D-11) and REQUIREMENTS.md (ROUTE-07). No silent rerouting — a fresh `app/admin/workflow/executions/page.tsx` was created over the pipeline-engine tables (`pipeline_executions` + `pipeline_execution_steps`). The legacy `workflow_executions` table and existing admin route are untouched.
-
-### Four Parameterized SQL Strings Confirmation
-
-The pipeline-executions route uses exactly four complete SQL strings selected by boolean flags:
-1. `fallbacksOnly && pipelineId !== null` — filters to one pipeline's fallbacks
-2. `fallbacksOnly && pipelineId === null` — all fallbacks across all pipelines
-3. `!fallbacksOnly && pipelineId !== null` — one pipeline, all executions with EXISTS-computed has_fallback
-4. `!fallbacksOnly && pipelineId === null` — all pipelines, all executions with EXISTS-computed has_fallback
-
-The alias-in-WHERE bug from the original plan pseudo-SQL (`${fallbacks_only ? 'AND has_fallback' : ''}`) is absent — the EXISTS predicate is inlined in WHERE, not referenced via alias.
-
-### POST /api/notification-channels channel_type Allowlist
-
-Unchanged — all four values remain accepted:
-`validTypes = ['teams', 'telegram', 'ntfy', 'webhook']`
-
-### Wire-up Deferred Note
-
-Links to `/admin/workflow/event-keys` and `/admin/workflow/executions` were NOT added to the `/admin/workflow` index page (`app/admin/workflow/page.tsx`). Both pages are functional at their URLs and reachable via direct navigation. A future small plan should add entries to the workflow admin index. This is noted in Known Stubs below.
-
-## Deviations from Plan
-
-None — all three tasks executed as specified. The one implementation choice (URLSearchParams.set() for building the fetch URL in the executions page) is semantically identical to string concatenation — `params.set('fallbacks_only', '1')` produces `fallbacks_only=1` in the final URL.
-
-## Known Stubs
-
-| Stub | File | Reason |
-|------|------|--------|
-| No navigation link to /admin/workflow/event-keys | app/admin/workflow/page.tsx (not in this plan) | Plan scope: build the target pages, not update the nav index. Future plan should add entries for event-keys and pipeline-executions to the workflow admin index |
-| No navigation link to /admin/workflow/executions | app/admin/workflow/page.tsx (not in this plan) | Same as above |
-
-Both pages are fully functional and reachable by URL — the stubs are navigation convenience items only, not blockers for the plan's goal.
-
-## Threat Flags
-
-No new network endpoints, auth paths, file access patterns, or schema changes at trust boundaries beyond what was declared in the plan's threat model (T-09-06-01 through T-09-06-08). All eight threats are addressed:
-
-- T-09-06-01: /api/notification-channels GET now requires requireAuth() — gap closed
-- T-09-06-02: Per-row authorization (isAdmin || isOwner) on [id] routes
-- T-09-06-03: Admin disclaimer rendered above personal-channel list
-- T-09-06-04: requireAdmin() + key regex + ON CONFLICT DO NOTHING on event keys POST
-- T-09-06-05: Admin-only writes via gated API; page HTML contains no extra secrets
-- T-09-06-06: SQL injection mitigated — pipeline_id /^\d+$/ validated, limit capped at 500, fallbacks_only is boolean flag selecting one of four static SQL strings
-- T-09-06-07: has_fallback reveals only presence of fallback, not user_id — admin-only context
-- T-09-06-08: POST channel_type allowlist preserved verbatim (teams/telegram/ntfy/webhook)
-
-## Self-Check: PASSED
-
-Files created:
-- app/api/admin/notify-event-keys/route.ts: FOUND
-- app/api/admin/notify-event-keys/[key]/route.ts: FOUND
-- app/admin/workflow/event-keys/page.tsx: FOUND
-- app/api/admin/pipeline-executions/route.ts: FOUND
-- app/admin/workflow/executions/page.tsx: FOUND
-
-Files modified:
-- app/api/notification-channels/route.ts: FOUND
-- app/api/notification-channels/[id]/route.ts: FOUND
-- app/admin/workflow/channels/page.tsx: FOUND
-
-Commits:
-- 47cab78: FOUND (Task 1 — channels owner column + auth)
-- 7f4ffa0: FOUND (Task 2 — event-keys CRUD)
-- 23a8c7c: FOUND (Task 3 — pipeline executions page + API)
-
-TypeScript: `npx tsc --noEmit` exit 0 — no errors.
diff --git a/.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md b/.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
deleted file mode 100644
index dad5e37..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
+++ /dev/null
@@ -1,459 +0,0 @@
-# Phase 9: User Profile & Preferences (NEW) - Context
-
-**Gathered:** 2026-05-09
-**Status:** Ready for planning
-
-
-## Phase Boundary
-
-A logged-in user reaches `/mobile/profile` from the More drawer and configures four
-classes of personal settings — **Timezone**, **Theme**, **Notifications**
-(per-event toggles split across channels), and **Channels** (personal Teams
-webhook URL + Pulse-minted ntfy topic). All four persist server-side per user
-and are cross-device consistent.
-
-The notify pipeline (`lib/services/pipeline-steps/notify.ts`) gains a per-step
-`route_to_user` block. When a step is configured with that block, notify.ts
-resolves a Pulse user from PipelineContext (via the field path declared in the
-step config), looks up that user's personal channel for the channel type, and
-delivers via the user's channel — falling back to the step's `channel_id`
-(global) when the user has no channel configured or the channel send fails,
-**with the fallback recorded as a warning** in the execution step output.
-
-Scope anchor (from ROADMAP.md): tap Profile/Account in the More drawer →
-`/mobile/profile` (real page, gated by `requireAuth()`); four sections persist
-per-user; cross-device consistent; notify pipeline routes per-user when an
-event has a user owner, falling back to global otherwise.
-
-**Out of scope for this phase** (preserved as deferred — see ``):
-- Browser-native Web Push (requires service worker — NOTIF/OFFLINE-02 v2)
-- Real Bell-icon notification list (NOTIF-01 v2)
-- A new admin page for cross-user channel inventory beyond what already exists
-
-
-
-
-## Implementation Decisions
-
-### Mobile push semantics (resolved)
-
-- **D-01:** "Mobile push notifications" in the goal is delivered via the
- **ntfy phone app**, NOT browser Web Push. ntfy's iOS/Android apps subscribe
- to a topic and receive pushes natively without a service worker. This keeps
- Phase 9 inside the milestone's "no SW, no offline" constraint
- (spec §4 / §7, REQUIREMENTS.md OFFLINE-01..02 deferred). Web Push remains
- v2.
-
-### Personal channels — model and limits
-
-- **D-02:** Each user has at most **one Teams webhook URL** and **one ntfy
- topic** at a time (singular both). The form has two inputs, each independently
- saveable and clearable. Matches the goal's wording (singular "Teams webhook
- URL, ntfy topic"). No list-of-channels UI in this phase.
-- **D-03:** **Storage: extend `notification_channels` with `owner_user_id TEXT
- REFERENCES "user"(id) ON DELETE CASCADE`.** Personal rows have
- `owner_user_id` set; global rows keep it `NULL`. Reuses the existing
- `notify.ts` shape — query becomes `WHERE channel_type = $1 AND
- (owner_user_id = $2 OR owner_user_id IS NULL)` with the personal row
- preferred. Cascade delete removes a user's channels when their account is
- removed.
-- **D-04:** **ntfy topic is Pulse-minted on first save**, not user-supplied.
- On the user's first ntfy save, the API generates a UUID-prefixed topic
- (e.g., `pulse-7f3a9c2b…`) and returns it to the client, which displays a
- one-tap subscribe link (`https://ntfy.sh/`) and a QR code so the user
- can subscribe in their ntfy app. The user MAY override with a custom topic
- via an "Edit advanced" disclosure but the default flow is mint-and-show. This
- keeps topics unguessable on the public ntfy.sh tier.
-- **D-05:** Teams webhook URL is **user-supplied free text** (validated as a
- URL pointing to one of the known Teams webhook hosts:
- `*.webhook.office.com`, `*.logic.azure.com`). No Pulse-minted Teams flow —
- Teams Incoming Webhooks must be created in Teams.
-- **D-06:** **Test-on-save** for both channel types: when the user saves a
- webhook URL or first mints an ntfy topic, the API issues a single best-effort
- test send ("Pulse channel verified — you can ignore this message."). The
- result is shown inline (success / HTTP-status / error message). The save
- itself succeeds even if the test fails — the user can persist a broken URL
- if they want to fix it later.
-- **D-07:** **Admin access: full edit.** Admins (role `admin` or
- `super-admin`) can read AND edit any user's personal channels via the
- existing `/admin/workflow/channels` page (extended with an
- "Owner" column and filter). Rationale: enables onboarding/offboarding fixes
- without forcing the user to log in. Trade-off accepted: admins can read
- another user's webhook URL as a secret. (Personal channels are *not*
- visible to non-admin users other than the owner.)
-
-### Notify pipeline — routing and delivery
-
-- **D-08:** **`notify` step config gains an optional `route_to_user` block.**
- Shape:
- ```jsonc
- {
- "channel_id": 7, // existing fallback channel (required)
- "route_to_user": { // OPTIONAL — when present, attempt user route first
- "source": "ticket", // PipelineContext key holding the entity
- "field": "assignedResourceID", // path within that entity
- "resolve": "autotask_resource_email", // resolver that turns the field into a user email
- "event_key": "ticket_assigned_to_me" // matches user subscription matrix (D-13)
- },
- "channel_type": "teams", // OPTIONAL preferred channel type; if omitted, try all configured
- "message": "...",
- "title": "..."
- }
- ```
- When `route_to_user` is present, notify.ts:
- 1. Reads `context[source][field]` (returns null/undefined → skip user route)
- 2. Calls the resolver (D-09) to obtain a user email
- 3. Looks up the Pulse user by email (no match → skip user route)
- 4. Checks the user's subscription matrix for `(event_key, channel_type)` — if disabled, **skip silently** (D-12)
- 5. Looks up the user's personal channel of that type — if missing, fall back to `channel_id` and log warning (D-11)
- 6. Sends via the personal channel; if HTTP send fails, fall back to `channel_id` and log warning (D-11)
- When `route_to_user` is absent, current behavior is unchanged.
-- **D-09:** **Resolvers shipped in v1**: `autotask_resource_email` (joins
- `resources.email` from the resource ID), `direct_email` (the field IS already
- an email string), `pulse_user_id` (the field IS already a Pulse user.id).
- Resolvers live in a new file `lib/services/pipeline-steps/notify-resolvers.ts`
- and are registered in a `Map`. Adding a new resolver = one
- file change, no DSL change. Datto/Veeam/Zabbix triggers don't get user
- resolvers in this phase (they have no clear user owner concept in
- PipelineContext today).
-- **D-10:** **Per-step "channel preference" priority.** When `channel_type` is
- set in `route_to_user`, only that channel is attempted before fallback. When
- omitted, notify.ts attempts ntfy first (instant push semantics), then Teams,
- then global fallback. The user's subscription matrix gates each attempt.
-- **D-11:** **No-channel / send-failure fallback semantics.** When the user
- route can't deliver (no channel of the requested type, or send returned
- non-2xx), notify.ts:
- 1. Sends via the step's `channel_id` (global) so the notification is not lost
- 2. Records `output.user_route_fallback = { reason: 'no_channel' | 'send_failed', user_id, channel_type, error? }` on the execution_step row
- 3. Returns `success: true` (the fallback succeeded) but with the warning embedded
- Admins surface these via a new `/admin/workflow/executions` filter
- ("Show executions that fell back to global"). One-line UI addition; no new
- table.
-- **D-12:** **Mute semantics: skip silently, no fallback.** When the user has
- the `(event_key, channel_type)` toggle DISABLED for the relevant
- channel-type in their subscription matrix, notify.ts records
- `skipped_reason: 'user_muted'` in the execution step output and **does NOT
- fall back to global**. Muting must actually mute — falling back to the
- global channel would defeat the user's opt-out. Distinct from the
- "channel missing / failed" fallback (D-11), which DOES fall back.
-
-### Event taxonomy & user subscriptions
-
-- **D-13:** **Event taxonomy is pipeline-driven**, not hardcoded. Pipelines
- declare `notify_event_key` on each `notify` step that uses `route_to_user`
- (already part of the `route_to_user` block — see D-08). The user-facing
- Notifications section of `/mobile/profile` derives its toggle list from
- `SELECT DISTINCT (config->'route_to_user'->>'event_key') FROM
- pipeline_steps WHERE step_type='notify' AND
- config->'route_to_user'->>'event_key' IS NOT NULL`, plus optional
- human-readable labels from a new `notify_event_keys` lookup table:
- ```sql
- CREATE TABLE notify_event_keys (
- key TEXT PRIMARY KEY, -- 'ticket_assigned_to_me'
- display_label TEXT NOT NULL, -- 'Ticket assigned to me'
- description TEXT, -- 'Fires when an Autotask ticket is assigned to your resource'
- sort_order INTEGER DEFAULT 0,
- is_active BOOLEAN DEFAULT true,
- created_at TIMESTAMP DEFAULT NOW()
- );
- ```
- Admins manage this list at `/admin/workflow/event-keys` (small new page,
- CRUD on label/description/sort). Keys not in the lookup table are still
- routable (use the raw key as the label) — the lookup is a humanization
- layer, not a gate.
-- **D-14:** **Subscription granularity: per event-key × per channel-type
- matrix.** Storage: new `user_event_subscriptions` table:
- ```sql
- CREATE TABLE user_event_subscriptions (
- user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
- event_key TEXT NOT NULL,
- channel_type VARCHAR(20) NOT NULL, -- 'teams' | 'ntfy' (matches notification_channels.channel_type)
- enabled BOOLEAN NOT NULL DEFAULT true,
- updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
- PRIMARY KEY (user_id, event_key, channel_type)
- );
- ```
- A row's absence is treated as "default = enabled" (opt-out model). The
- Notifications section of `/mobile/profile` shows a row per active
- `notify_event_keys` entry × column per personal channel-type the user has
- configured (rows collapse to a single "channel" column when only one
- channel is configured).
-- **D-15:** **Default subscriptions for new users / new event keys: enabled.**
- When a row is missing from `user_event_subscriptions`, `enabled = true` is
- assumed. Users opt out, not in. New event keys added by admins go live for
- everyone immediately.
-
-### Theme — persistence and bridge
-
-- **D-16:** **Server is canonical, next-themes stays as render layer.**
- next-themes continues to handle FOUC (the inline script) and the
- `class="dark"` toggle. On session load (and after sign-in), a small
- client-side effect compares `session.user.theme` to `theme` from
- `useTheme()` and calls `setTheme(session.user.theme)` if different. Server
- writes go through `PUT /api/me/theme`. Sign-out leaves the
- last-rendered theme as a localStorage tail (acceptable — auth boundary).
-- **D-17:** **On sign-in, always override the local theme with the server
- value.** No "explicit-vs-default" detection, no toast. Cross-device
- consistency is the entire point. A brief flash on sign-in is acceptable.
-- **D-18:** **Storage: new column `theme TEXT NOT NULL DEFAULT 'system'` on
- the `user` table**, exposed via Better Auth `additionalFields` so
- `session.user.theme` is available in the same way `session.user.timezone`
- is today (Phase 7.1 precedent). Allowed values: `'light' | 'dark' |
- 'system'`. Validation in the API route mirrors the
- timezone IANA pattern (allowlist of three strings).
-- **D-19:** **Default for users who never visit settings: `'system'`.** Maps
- to OS preference at render time via next-themes — zero behavior change for
- existing users. Backfill script for existing rows: `UPDATE "user" SET theme
- = 'system' WHERE theme IS NULL`.
-- **D-20:** **Theme applies to ALL routes**, not just `/mobile/*`. The session
- drives the theme app-wide; the `/mobile/profile` Theme section just exposes
- the toggle on a phone-friendly surface. Existing `ThemeToggle` desktop
- dropdown is left in place — when the user toggles it, the new
- `PUT /api/me/theme` is also called (theme-toggle becomes
- session-aware). This keeps the desktop affordance working without forcing
- the user to navigate to mobile to change theme.
-
-### Timezone — building on Phase 7.1
-
-- **D-21:** **Reuse `GET/PUT /api/me/timezone` and `useUserTimezone()`
- unchanged.** Phase 9 only adds the **chooser UI** on `/mobile/profile`. The
- chooser is a shadcn `Combobox` (or a `Select` if the IANA list overflows)
- populated from `Intl.supportedValuesOf('timeZone')` plus the four
- `EXTRA_ALLOWED_TIMEZONES` (`UTC`, `Etc/UTC`, `GMT`, `Etc/GMT`) per the
- existing API allowlist. Currently-rendering timezone shown alongside as
- read-only ("Your current time: 2026-05-09 14:32 in America/Chicago").
-- **D-22:** **No new timezone API.** Phase 9 doesn't touch
- `/api/me/timezone` — the route already validates and writes correctly. The
- mobile chooser PUTs to it the same way a future desktop chooser would.
-
-### Page surface — `/mobile/profile`
-
-- **D-23:** **Single page, four sections in this order: Timezone → Theme →
- Notifications → Channels.** No sub-routes. Each section is a shadcn `Card`
- with header + content. The page is gated by `requireAuth()` (server
- component shell, client components within for the interactive forms).
-- **D-24:** **Save model: per-section, immediate (no global "Save" button).**
- Toggles save on change (debounced 400ms for matrix toggles); inputs save
- on blur or explicit "Save" button next to the input. Matches the
- one-section-at-a-time UX of `/admin/integrations`. Sonner toasts confirm
- saves; failures show inline error + don't optimistically update.
-- **D-25:** **Drawer wiring: `MoreDrawer.tsx` Account section gains a
- "Profile & preferences" link** above the Sign-out row, routing to
- `/mobile/profile`. The "current user" identity row in the drawer becomes
- the link itself (tappable area expanded). Sign-out stays the trailing
- destructive action.
-
-### Desktop /settings parity — explicit non-goal for Phase 9
-
-- **D-26:** **Phase 9 does NOT touch `/settings` (desktop) beyond
- `theme-toggle` becoming session-aware (D-20).** The desktop page keeps its
- current Name-only profile form. A future phase can add full parity if
- needed. Rationale: phase boundary is mobile profile per ROADMAP.md; desktop
- read of the new fields works correctly via session — only the *editor* UX is
- mobile-only this phase.
-
-### Claude's Discretion
-
-- Exact layout and spacing within the four sections (use Phase 8 / Phase 7
- conventions: `Card` + `space-y-3 p-4`, `text-2xl font-semibold` headings,
- `gap-3` between rows)
-- Whether the ntfy QR is generated client-side (e.g., with a tiny
- `qrcode.react`-style helper) or server-side as a data URI
-- Combobox vs Select for the timezone picker (depends on render-time count of
- `Intl.supportedValuesOf('timeZone')` — likely Combobox)
-- Test-message wording for D-06
-- Whether to add a "test now" button on the Channels section (independent of
- initial save)
-- Skeleton state for the Notifications matrix while pipelines/event-keys load
-- Validation regex for the Teams webhook URL — `*.webhook.office.com` and
- `*.logic.azure.com` are the two known live hosts; planner can refine
-- Whether the desktop `/admin/workflow/channels` "Owner" column also gets a
- filter widget or just sortable column
-
-
-
-
-## Canonical References
-
-**Downstream agents MUST read these before planning or implementing.**
-
-### Phase scope and roadmap
-- `.planning/ROADMAP.md` §"Phase 9: User Profile & Preferences (NEW)" — phase
- goal, depends-on, success criteria
-- `.planning/REQUIREMENTS.md` — TZ-01..04 (Phase 7.1 completed), NOTIF-01/02,
- OFFLINE-01/02, EDIT-01/02 (all v2-deferred and explicitly NOT Phase 9 scope)
-- `docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §4 (no SW/offline),
- §5.1 (Bell placeholder), §7 (out-of-scope) — defines the constraint that
- forces "mobile push" to mean ntfy-app push, not Web Push
-
-### Notify pipeline — current shape we're extending
-- `lib/services/pipeline-steps/notify.ts` — current channel-only routing
- (Teams / Telegram / ntfy / generic webhook). Phase 9 adds `route_to_user`
- awareness without breaking existing config.
-- `lib/types/pipeline.ts` — `NotificationChannel`, `PipelineStep`,
- `PipelineContext`, `StepExecutorResult` types (Phase 9 will add
- `route_to_user` shape and resolver registry types here)
-- `migrations/033_create_pipeline_engine_tables.sql` — `notification_channels`
- and `pipeline_steps` schemas (Phase 9 adds `owner_user_id` column to channels
- + new `notify_event_keys`, `user_event_subscriptions` tables)
-
-### Auth & session — additionalFields precedent
-- `lib/auth.ts` — `additionalFields` block currently exposing `role`,
- `requires_setup`, `timezone`. Phase 9 adds `theme` here.
-- `migrations/012_create_auth_tables.sql` — base `user` table schema
-- `migrations/083_add_user_timezone.sql` — Phase 7.1 precedent for adding a
- scalar column to `user` and exposing via `additionalFields`
-
-### Timezone (already complete in Phase 7.1) — reused as-is
-- `app/api/me/timezone/route.ts` — IANA-validated `GET/PUT` endpoint, writes
- session.user.id only. Phase 9 reuses verbatim.
-- `lib/hooks/use-user-timezone.ts` — client-side hook reading from session
-- `lib/services/user-timezone.ts` — server-side helper
-
-### Theme — current next-themes wiring
-- `components/theme-provider.tsx` — wraps `NextThemesProvider`
-- `components/theme-toggle.tsx` — current desktop dropdown (becomes
- session-aware in D-20)
-- `app/layout.tsx` — `` mount + inline FOUC script
-
-### Mobile shell — drawer entry point
-- `components/mobile/MoreDrawer.tsx` — Account section gains the
- Profile & preferences link (D-25)
-- `app/mobile/layout.tsx` — drawer parent that owns `drawerOpen` state
-
-### Existing admin surfaces we extend (not replace)
-- `app/admin/workflow/channels/page.tsx` — gains "Owner" column + filter
- for personal channels (D-07)
-- (new) `app/admin/workflow/event-keys/page.tsx` — admin CRUD for
- `notify_event_keys` lookup (D-13)
-
-### Existing settings surfaces — explicit NOT touched in Phase 9
-- `app/settings/page.tsx` — desktop /settings, kept as Name-only form (D-26)
-- `app/api/settings/profile/route.ts` — kept Name-only
-
-### Component conventions to mirror
-- `components/mobile/EngagementProfileMetricGrid.tsx` and Phase 8 components —
- Card + spacing scale (`text-2xl`, `gap-3`, `space-y-3`, `p-4`)
-- `components/admin/DataTable.tsx` — admin Channels table extension (D-07)
-
-
-
-
-## Existing Code Insights
-
-### Reusable Assets
-
-- **`lib/auth.ts` `additionalFields`** — Already exposes `timezone`; Phase 9
- adds `theme` and reads channel state through normal session refresh. No
- Better Auth config plumbing needed beyond one new field.
-- **`/api/me/timezone` pattern** — Establishes the convention: per-user
- scalar at `/api/me/`, GET returns `{value, source}`, PUT validates +
- writes `session.user.id` only. Phase 9 replicates this for theme
- (`/api/me/theme`) and creates two new aggregate routes
- (`/api/me/channels`, `/api/me/notification-subscriptions`).
-- **`lib/services/pipeline-steps/notify.ts`** — Already switches on
- `channel.channel_type`; Phase 9 wraps the existing channel-resolution path
- in a per-user resolution that *upgrades* the resolved channel before
- sending. Existing pipelines without `route_to_user` are unchanged.
-- **`MoreDrawer` Account section** — Already has the user identity row;
- Phase 9 wraps it as a `Link` to `/mobile/profile` and adds a "Profile &
- preferences" line above Sign-out.
-- **`ThemeToggle` + `next-themes`** — Render layer is reusable; Phase 9
- only adds a session-sync effect and `PUT /api/me/theme` write-through.
-- **`Intl.supportedValuesOf('timeZone')` validation** — Already lives in
- `app/api/me/timezone/route.ts` with the four extra-allowlist constants;
- Phase 9 reuses both for the chooser dropdown.
-
-### Established Patterns
-
-- **kebab-case files, PascalCase components** (CLAUDE.md) — new components
- (e.g., `ProfileTimezoneSection`, `ProfileNotificationMatrix`) live under
- `components/mobile/profile/` to keep Phase 9 components grouped.
-- **No ORM, manual snake_case → camelCase transforms in route handlers**
- (CLAUDE.md) — new tables (`user_event_subscriptions`, `notify_event_keys`)
- use snake_case; API responses translate.
-- **`requireAuth()` / `requireAdmin()` from `lib/auth-utils.ts`** — every
- new route uses these; no middleware-level changes.
-- **No Zod in route handlers (CLAUDE.md)** — match existing per-route manual
- validation. Use the `Intl.supportedValuesOf` pattern for timezone, an
- allowlist for theme, URL parsing for Teams webhook hosts, regex for ntfy
- topic format.
-- **Numbered migrations, never edited** — new migration(s) at the end of
- the sequence (current head: 083). Likely two to three migrations:
- `084_add_user_theme.sql`, `085_personal_notification_channels.sql`,
- `086_user_event_subscriptions.sql` — let the planner decide split vs
- combined.
-- **Side-effect-free imports** (CLAUDE.md) — notify.ts is already imported
- by the pipeline engine; resolver registry must be safe to import without
- starting workers.
-
-### Integration Points
-
-- **`MoreDrawer.tsx` Account section** — adds Profile link above Sign-out
-- **`ThemeToggle.tsx`** — `setTheme()` callback also calls
- `fetch('/api/me/theme', {method: 'PUT'})`
-- **`lib/auth.ts` `additionalFields`** — adds `theme` field
-- **`lib/services/pipeline-steps/notify.ts` `executeNotify`** — adds the
- user-route branch before existing channel lookup
-- **`app/admin/workflow/channels/page.tsx`** — adds Owner column + filter
-- **(new) `app/admin/workflow/event-keys/page.tsx`** — admin CRUD for the
- event-key lookup table
-- **`/api/me/*` family** — three new routes:
- `/api/me/theme` (GET/PUT),
- `/api/me/channels` (GET / PUT teams / PUT ntfy / DELETE / POST :test),
- `/api/me/notification-subscriptions` (GET full matrix / PUT row)
-
-
-
-
-## Specific Ideas
-
-- "I like option 1 but feel it should also be logged so broken channels can
- be fixed/removed" — drove D-11 (fallback + warning) and the admin
- executions filter for surfacing fallback events.
-- The user explicitly chose **full admin edit access** for personal channels
- (D-07) accepting the "admins can read your webhook URL as a secret"
- trade-off, prioritizing the ability to fix broken channels for
- onboarding/offboarding.
-- The "skip silently when muted" answer (D-12) signals a strong intent that
- user opt-outs are honored — even when the pipeline has a fallback channel
- configured, a muted user-route does NOT fall back. This is distinct from
- the "no channel / send failed" fallback semantics, which DOES fall back.
-
-
-
-
-## Deferred Ideas
-
-These came up implicitly during analysis. Captured so they're not lost.
-
-- **Browser-native Web Push (NOTIF/OFFLINE-02)** — explicitly v2 per spec §4 /
- §7. Phase 9's "mobile push" is delivered via the ntfy phone app instead.
-- **Real Bell-icon notification list (NOTIF-01)** — placeholder remains;
- future phase wires it once the in-app feed is defined.
-- **Desktop `/settings` parity for the four sections (D-26)** — desktop
- /settings stays Name-only this phase. Future phase can add a desktop view
- that mirrors `/mobile/profile`.
-- **Resolvers for Datto/Veeam/Zabbix triggers (D-09)** — these triggers don't
- have a clear "user owner" concept in PipelineContext today. Adding them is
- one new resolver per source; defer until a use-case emerges.
-- **Multi-channel-per-type per user (D-02)** — one Teams + one ntfy is the
- v1 limit. If managers ever need "send to my personal Teams AND a team Teams
- channel" routing, a future phase could relax the singular constraint.
-- **Test-message scheduling / re-test on demand** — the in-form "test on save"
- (D-06) is best-effort. A "test now" button independent of save is in
- Claude's Discretion; the planner may include it depending on UX cost.
-- **Channel-rotation flow** — when a user updates their Teams webhook URL,
- there's no in-app reminder for any pipelines that referenced the old one.
- Acceptable v1 — `owner_user_id` joins by user, not by URL, so renaming the
- webhook is transparent to pipelines.
-- **Auto-disable broken channels** — when a personal channel has N
- consecutive send failures, auto-disable. Out of scope; current model is
- "fall back + warn" (D-11) and let admins/users react.
-
-
-
----
-
-*Phase: 09-user-profile-preferences-new*
-*Context gathered: 2026-05-09*
diff --git a/.planning/phases/09-user-profile-preferences-new/09-DISCUSSION-LOG.md b/.planning/phases/09-user-profile-preferences-new/09-DISCUSSION-LOG.md
deleted file mode 100644
index 3cafee1..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-DISCUSSION-LOG.md
+++ /dev/null
@@ -1,188 +0,0 @@
-# Phase 9: User Profile & Preferences (NEW) - Discussion Log
-
-> **Audit trail only.** Do not use as input to planning, research, or execution agents.
-> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
-
-**Date:** 2026-05-09
-**Phase:** 09-user-profile-preferences-new
-**Mode:** discuss (interactive)
-**Areas discussed:** Preferences & channel data model, Theme persistence vs next-themes, Event taxonomy + notify.ts routing
-**Areas declined:** Mobile push semantics — resolved by Claude per spec constraint (no SW → ntfy app push, not Web Push)
-
----
-
-## Selected gray areas (multiSelect)
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Mobile push semantics (resolve conflict) | Goal says 'mobile push notifications' but spec §4/§7 defers SW + Web Push to v2. Resolve: ntfy-as-push, browser Web Push, or ship UI now wire later. | |
-| Preferences & channel data model | Where do per-user theme/channels/event-subscriptions live? Drives schema, migration count, notify.ts integration. | ✓ |
-| Theme persistence vs next-themes | Bridge strategy between server-canonical theme and existing next-themes (localStorage + FOUC script). | ✓ |
-| Event taxonomy + notify.ts routing | Which events can a user subscribe to AND how does notify.ts know an event has a user owner? | ✓ |
-
----
-
-## Preferences & channel data model
-
-### Q1 — Channel count
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| One Teams + one ntfy (Recommended) | At most one webhook URL and one ntfy topic per user. Two inputs. | ✓ |
-| Many of each (list with add/remove) | Multiple per channel-type, with "which one fires" selector. | |
-| One channel total (Teams OR ntfy) | One delivery method only. | |
-
-**User's choice:** One Teams + one ntfy
-**Notes:** Matches the goal's singular wording.
-
-### Q2 — No-channel fallback
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Fall back to global channels (Recommended) | Per-user channel preferred; global as safety net. | (chosen with addition) |
-| Drop silently, no fallback | Pipeline records 'no channel' and moves on. | |
-| Drop + audit log (visible failure) | Mark execution step warning. | |
-
-**User's choice:** "I like option 1 but feel it should also be logged so broken channels can be fixed/removed"
-**Notes:** Hybrid: fall back to global AND log the fallback as a warning on the execution step row so admins can fix broken channels without losing notifications. Drove **D-11** in CONTEXT.md.
-
-### Q3 — Admin access to personal channels
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Read-only visibility (Recommended) | Admins audit but cannot edit; user-only writes. | |
-| No admin visibility — personal means personal | Strongest privacy; admins see only existence. | |
-| Full edit access for admins | Admins fix broken channels for users; admins can read another user's webhook URL as a secret. | ✓ |
-
-**User's choice:** Full edit access for admins
-**Notes:** Trade-off accepted — onboarding/offboarding ergonomics > webhook-URL secrecy. Drove **D-07**.
-
-### Q4 — ntfy topic minting
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Pulse generates a random topic, user subscribes (Recommended) | UUID-prefixed topic minted on save; user gets subscribe link/QR; can override. | ✓ |
-| User supplies their own topic | Free-text input; footgun on guessable topics. | |
-| Require auth_token for ntfy | Self-hosted/paid ntfy only; rules out free tier. | |
-
-**User's choice:** Pulse generates a random topic, user subscribes
-**Notes:** Drove **D-04** plus the override path via "Edit advanced" disclosure for power users.
-
----
-
-## Theme persistence vs next-themes
-
-### Q1 — Bridge strategy
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Keep next-themes, server is canonical (Recommended) | Render layer unchanged; session-load effect calls setTheme(server). | ✓ |
-| Replace next-themes with server-only | Hand-roll inline FOUC script; reimplement system listener. | |
-| Dual-store, last-write-wins | Both localStorage and server hold a value with conflict resolution. | |
-
-**User's choice:** Keep next-themes, server is canonical
-**Notes:** Smallest delta from existing code; preserves zero-flash on auth pages. Drove **D-16**.
-
-### Q2 — Sign-in override behavior
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Always override with server value (Recommended) | Cross-device consistency by default; brief flash acceptable on auth boundary. | ✓ |
-| Only override if server value was set explicitly | Detect default vs explicit; skip if default. | |
-| Never override; show a toast | Lowest-surprise; users usually ignore the toast. | |
-
-**User's choice:** Always override with server value
-**Notes:** Drove **D-17**.
-
-### Q3 — Theme storage location
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Column on user via Better Auth additionalFields (Recommended) | Matches Phase 7.1 timezone precedent; session.user.theme works directly. | ✓ |
-| JSONB preferences blob on user | Single column for future scalars; harder to index/expose individually. | |
-| Separate user_preferences key/value table | Most extensible; heaviest reads. | |
-
-**User's choice:** Column on user via Better Auth additionalFields
-**Notes:** Drove **D-18**.
-
-### Q4 — Default theme value
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| 'system' (Recommended) | OS preference at render time; zero behavior change for existing users. | ✓ |
-| 'dark' | Force dark first visit; surprising on bright screens. | |
-| 'light' | Force light; Pulse accents look worse in light. | |
-
-**User's choice:** 'system'
-**Notes:** Drove **D-19**.
-
----
-
-## Event taxonomy + notify.ts routing
-
-### Q1 — Routing key (how notify.ts identifies user owner)
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Step config `route_to_user` block + user_resolver (Recommended) | Local DSL change; resolver maps PipelineContext field to user email. | ✓ |
-| Generic `user_owner_email` on PipelineContext | Every trigger learns to populate; cleaner DSL but heavier change. | |
-| Tag pipelines with fixed user owner | Static, useful for personal pipelines but not for dynamic ticket assignees. | |
-
-**User's choice:** Step config `route_to_user` block + user_resolver
-**Notes:** Drove **D-08**, **D-09**, **D-10**. Local change to notify.ts; new file `notify-resolvers.ts` registers resolvers.
-
-### Q2 — Event taxonomy source
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Driven by pipelines tagged 'subscribable' (Recommended) | event_key declared on each route_to_user step; user UI derives toggle list from distinct keys. | ✓ |
-| Small fixed v1 set | Hardcode three event keys; new types = migration. | |
-| No taxonomy — single on/off | Skip per-event toggles; violates the goal's wording. | |
-
-**User's choice:** Driven by pipelines tagged 'subscribable'
-**Notes:** Drove **D-13** + the new `notify_event_keys` lookup table for human-readable labels.
-
-### Q3 — Subscription granularity
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Per event-type × per channel matrix (Recommended) | Rows = events, columns = channels; user routes urgent-vs-non-urgent independently. | ✓ |
-| Per event-type, channel auto-picked | Single toggle per event; system picks preferred channel. | |
-| One 'mute all but X' override per channel | Inverted UX; users opted-in by default with explicit mutes. | |
-
-**User's choice:** Per event-type × per channel matrix
-**Notes:** Drove **D-14** and the `user_event_subscriptions` table shape.
-
-### Q4 — Mute behavior when toggle is disabled
-
-| Option | Description | Selected |
-|--------|-------------|----------|
-| Skip silently (Recommended) | Honor opt-out; no fallback. Records skipped_reason on execution_step. | ✓ |
-| Fall back to global | Treat mute = no channel; defeats the toggle. | |
-| Skip + audit warning | Compliance-friendly but noisy. | |
-
-**User's choice:** Skip silently
-**Notes:** Drove **D-12**. Distinct from the "channel missing / failed" fallback (D-11), which DOES fall back to global.
-
----
-
-## Claude's Discretion
-
-- **Mobile push semantics (D-01)** — User did NOT select this gray area; Claude resolved per spec constraint: "mobile push" via ntfy phone app, not browser Web Push (which would require a service worker — out of scope per spec §4 and OFFLINE-02 v2 deferral).
-- Page layout, save UX patterns, exact form validation regex
-- ntfy QR generation strategy (client-side helper vs server data URI)
-- Whether the desktop `/settings` page eventually mirrors these sections (D-26 says no for Phase 9)
-- "Test now" button independent of save (planner decides on cost vs UX)
-
-## Deferred Ideas
-
-(See `` section in CONTEXT.md.)
-
-- Browser-native Web Push (NOTIF/OFFLINE-02)
-- Real Bell-icon notification list (NOTIF-01)
-- Desktop /settings parity for the four sections
-- Resolvers for Datto/Veeam/Zabbix triggers
-- Multi-channel-per-type per user
-- Test-message scheduling / re-test on demand
-- Channel-rotation flow
-- Auto-disable broken channels after N failures
diff --git a/.planning/phases/09-user-profile-preferences-new/09-HUMAN-UAT.md b/.planning/phases/09-user-profile-preferences-new/09-HUMAN-UAT.md
deleted file mode 100644
index 81f5231..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-HUMAN-UAT.md
+++ /dev/null
@@ -1,93 +0,0 @@
----
-status: diagnosed
-phase: 09-user-profile-preferences-new
-source: [09-VERIFICATION.md]
-started: 2026-05-10T11:58:07Z
-updated: 2026-05-11T02:30:00Z
----
-
-## Current Test
-
-[testing complete]
-
-## Tests
-
-### 1. /mobile/profile full render (4 sections)
-expected: Sign in, navigate to /mobile/profile on a mobile-sized browser. Four Cards render in order with no console errors. Timezone Combobox shows saved timezone; Theme shows active selection; Notifications shows matrix or empty-state; Channels shows configured state.
-result: issue
-reported: "interface looks fine, but the ntfy feature should use https://ntfy.wulfconsulting.cloud with user 'pulse', token in .env (NTFY_PULSE_TOKEN), and read-write access scoped to noc-* and soc-* topics. Publish format: POST https://ntfy.wulfconsulting.cloud/{topic} with Authorization: Bearer and Content-Type: text/plain."
-severity: major
-note: UI part of test 1 passes — the issue is feature integration target. ntfy is currently pointed at public ntfy.sh and mints pulse-* topics, neither of which fits the company ntfy infrastructure.
-
-### 2. More drawer Profile & preferences link
-expected: Open More drawer from mobile shell. Identity row is tappable link and 'Profile & preferences' row appears above Sign-out. Both links navigate to /mobile/profile; Sign-out remains with destructive styling.
-result: pass
-
-### 3. Timezone Combobox save and debounce
-expected: Change timezone in Combobox. 400ms delay, PUT fires, sonner toast appears ("Timezone updated"), current-time line updates. No immediate PUT (400ms debounce enforced).
-result: pass
-
-### 4. Theme radio rows + write-through + cross-device persistence
-expected: Tap 'Dark' in Theme section. Reload in a different session. Theme switches immediately (next-themes); PUT /api/me/theme succeeds; new session loads Dark theme.
-result: pass
-note: Initially failed with "Failed to update theme" — root cause was PUT route using `updated_at` (unquoted snake_case) but Better Auth's `"user"` table uses `"updatedAt"` (quoted camelCase). Fixed in 041fb16. Verified working on retry. Same bug found and fixed in app/api/settings/profile/route.ts and lib/bootstrap.ts (3dd379d).
-
-### 5. Teams webhook inline error on 400
-expected: Enter `https://evil.com` in Teams URL input and save. Inline `text-xs text-destructive` error under input; no 'Channel saved' toast.
-result: pass
-note: User confirmed both the inline error (under input) and the generic "Failed to save channel" toast appeared. No success toast. Spec requirement is satisfied — the inline error gives the actionable reason.
-
-### 6. ntfy custom topic inline error on 400
-expected: Open 'Edit advanced', enter `bad space` as custom topic, save. Inline error 'topic must match ^[A-Za-z0-9_-]{6,64}$' below Input.
-result: pass
-
-### 7. ThemeToggle desktop write-through
-expected: Use desktop ThemeToggle to switch theme. Open /mobile/profile in another session. Both sessions show same theme.
-result: pass
-
-### 8. Admin channels Owner column + filter
-expected: As admin, open /admin/workflow/channels. Confirm Owner badges; test filter widget. Global rows show 'Global' badge; personal rows show 'Personal: email'; filter hides/shows by type.
-result: pass
-
-### 9. Admin executions fallback filter
-expected: As admin, open /admin/workflow/executions. Toggle 'Show only fallbacks'. Only rows with user_route_fallback appear; empty state message when none exist.
-result: pass
-
-## Summary
-
-total: 9
-passed: 8
-issues: 1
-pending: 0
-skipped: 0
-blocked: 0
-
-## Gaps
-
-- truth: "Personal ntfy channels publish to the company ntfy infrastructure with bearer auth and a reserved topic prefix"
- status: failed
- reason: "User reported: ntfy feature should use https://ntfy.wulfconsulting.cloud (not ntfy.sh) with the existing `pulse` admin user, bearer token from NTFY_PULSE_TOKEN in .env. Topic prefix `pulse-me-` (noc-*/soc-* are reserved for NOC/SOC operations)."
- severity: major
- test: 1
- root_cause: |
- Personal channel implementation was scaffolded against the public ntfy.sh server with a `pulse-` topic prefix and no enforced auth. The production deployment uses a private ntfy instance (https://ntfy.wulfconsulting.cloud) requiring bearer auth, and the `pulse-` prefix was an arbitrary placeholder — production needs the namespaced `pulse-me-` prefix to coexist with reserved `noc-*`/`soc-*` ACLs.
- artifacts:
- - path: "lib/services/personal-channels.ts"
- issue: "mintNtfyTopic mints `pulse-XXXXXXXX`; NTFY_TOPIC_RE accepts any [A-Za-z0-9_-]{6,64}; sendChannelTest defaults to https://ntfy.sh and reads auth_token from channel.config instead of env."
- - path: "lib/services/pipeline-steps/notify.ts"
- issue: "ntfy publish (when route_to_user resolves to ntfy) inherits the same defaults — public server, no enforced bearer auth."
- - path: "lib/services/ticket-digest-service.ts"
- issue: "Other ntfy publish caller — same defaults."
- - path: "components/mobile/profile/ProfileChannelsSection.tsx"
- issue: "QR code + subscribe link point at https://ntfy.sh/{topic}; custom-topic inline error message references the generic regex pattern, not the required prefix."
- missing:
- - "Env vars: NTFY_BASE_URL (server, default https://ntfy.wulfconsulting.cloud), NEXT_PUBLIC_NTFY_BASE_URL (client, same default)."
- - "mintNtfyTopic() must mint `pulse-me-XXXXXXXX` (keep 8 hex chars entropy)."
- - "NTFY_TOPIC_RE must enforce `^pulse-me-[A-Za-z0-9-]{6,64}$` for personal topics; reject `pulse-`, `noc-`, `soc-`, arbitrary names."
- - "sendChannelTest (ntfy case) must default server_url to NTFY_BASE_URL and ALWAYS send Authorization: Bearer ${NTFY_PULSE_TOKEN} for personal channels (drop the channel.config.auth_token path here)."
- - "Notify pipeline ntfy publish + ticket-digest-service ntfy publish must use the same base URL + bearer auth pattern."
- - "ProfileChannelsSection QR + subscribe link target NEXT_PUBLIC_NTFY_BASE_URL; inline error copy updated to 'Topic must start with pulse-me-'."
- - "Cleanup: any test rows in notification_channels with `pulse-` topic prefix should be deleted (notification_channels table introduced in migration 085, low-volume QA data only)."
- out_of_scope:
- - "Admin override to set custom server_url / auth_token per-channel (currently supported via channel.config for legacy ntfy.sh use cases — leave that path; only PERSONAL channels are forced to the company server)."
- debug_session: ""
diff --git a/.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md b/.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md
deleted file mode 100644
index 97e5d80..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md
+++ /dev/null
@@ -1,386 +0,0 @@
----
-phase: 9
-slug: user-profile-preferences-new
-status: approved
-shadcn_initialized: true
-preset: new-york / neutral / cssVariables
-created: 2026-05-09
-reviewed_at: 2026-05-09
----
-
-# Phase 9 — UI Design Contract
-## User Profile & Preferences
-
-> Visual and interaction contract for `/mobile/profile` and its supporting surfaces.
-> Generated by gsd-ui-researcher. Consumed by gsd-planner, gsd-executor, gsd-ui-auditor.
-
----
-
-## Design System
-
-| Property | Value |
-|----------|-------|
-| Tool | shadcn (existing, initialized) |
-| Preset | new-york, baseColor: neutral, cssVariables: true |
-| Component library | Radix UI (via shadcn) |
-| Icon library | lucide-react |
-| Font (sans) | IBM Plex Sans — weights 300/400/500/600/700 |
-| Font (mono) | IBM Plex Mono — used on numeric/ID/timestamp values only |
-
-Source: `components.json`, `app/styles/brand.css`, `DESIGN.md`
-
-No new third-party registries. shadcn official components only.
-
----
-
-## Spacing Scale
-
-Standard 8-point scale. Matches every prior mobile phase (Phase 7, Phase 8 precedent).
-
-| Token | Value | Usage |
-|-------|-------|-------|
-| xs | 4px | Icon gaps, badge padding, tight inline spacing |
-| sm | 8px | Label-to-value gaps within a row, compact list rows |
-| md | 16px | Card horizontal padding (`px-4`), form-row gap |
-| lg | 24px | Between Card sections (`space-y-6` on the page) |
-| xl | 32px | — (not used on mobile; max-w-lg layout implies tighter scale) |
-| 2xl | 48px | — (reserved for page-level only; mobile skips this) |
-| 3xl | 64px | — |
-
-**Exceptions:**
-- Touch targets: all interactive rows, buttons, and toggle rows must meet 44px minimum height (`min-h-[44px]`). Applied via `py-3` or explicit `min-h-[44px]` where the natural content height would fall short.
-- Card body padding: `px-4 py-4` (matches `EngagementProfileMetricGrid` and `EngagementProfileHeader` precedent from Phase 8).
-- Page outer padding: `px-4 pb-safe` — 16px sides plus safe-area inset at the bottom (Phase 8 pattern).
-- Section-to-section gap on the page scroll body: `space-y-4` (matches Phase 8 profile page).
-- Notification matrix toggle rows: `py-3 gap-3` between icon, label, and Switch to maintain 44px tap height.
-
-Source: Phase 8 `app/mobile/engagement/[userId]/page.tsx`, `components/mobile/EngagementProfileHeader.tsx`, `DESIGN.md §3`
-
----
-
-## Typography
-
-Four roles. Same scale as Phase 7 / Phase 8 mobile components.
-
-| Role | Size | Weight | Line Height | Usage |
-|------|------|--------|-------------|-------|
-| Body | 14px (`text-sm`) | 400 (regular) | 1.5 | List row labels, Card description copy, helper text |
-| Label | 12px (`text-xs`) | 400 (regular) | 1.4 | Muted secondary labels (`text-muted-foreground`), timestamps, section sub-labels |
-| Heading | 20px (`text-xl`) | 600 (semibold) | 1.2 | Page H1, Card titles, primary text values (not numeric) |
-| Display | 24px (`text-2xl`) | 600 (semibold) | 1.1 | Numeric KPI values in metric grids (matches `EngagementProfileMetricGrid`) |
-
-**Additional rules:**
-- Section header labels inside the More Drawer / Card headers: `text-xs font-semibold text-muted-foreground uppercase tracking-wider` (matches `MoreDrawer.tsx` section labels). These use the 12px Label role — do not introduce a fifth size.
-- Numeric data (timezone current-time display, any channel IDs): use `.num` utility class from `brand.css` (IBM Plex Mono, tabular-nums).
-- `text-2xl font-semibold` is reserved for the page `` and numeric metric displays. Card titles use `text-xl font-semibold` (Heading role, 20px).
-
-Source: `DESIGN.md §2 Type`, `components/mobile/EngagementProfileMetricGrid.tsx`, `components/mobile/MoreDrawer.tsx`
-
----
-
-## Color
-
-All color values use CSS variable tokens — never raw Tailwind palette or hex values in components.
-
-| Role | Token | Usage |
-|------|-------|-------|
-| Dominant (60%) | `bg-background` / `text-foreground` | Page surface, scrollable content area |
-| Secondary (30%) | `bg-card` / `border` / `bg-muted` | Card surfaces, section dividers, input backgrounds, skeleton fills |
-| Accent (10%) | `text-primary` / `bg-primary` | Reserved list below |
-| Destructive | `text-destructive` / `bg-destructive/10` | Sign-out action in drawer, error inline messages only |
-
-**Accent (`text-primary` / `bg-primary`) is reserved exclusively for:**
-1. Active state of the Save/submit button on the Channels section
-2. The "Copy subscribe link" or ntfy subscribe link text
-3. Focus rings (`ring-ring` — inherits from brand blue token)
-4. Avatar initials background tint (`bg-primary/15 text-primary`) — matches `MoreDrawer.tsx` pattern
-
-**Status semantic colors (outside the 60/30/10 set):**
-- Test-send success: `bg-green-500/15 text-green-600` (badge pattern from `DESIGN.md §2`)
-- Test-send failure: `bg-destructive/15 text-destructive`
-- Channel verified indicator: `text-green-600` inline next to the input
-
-**Light/Dark:** All tokens automatically adapt. The `theme` column in `user` drives next-themes; components use tokens only, never hard-coded colors.
-
-Source: `app/globals.css`, `app/styles/brand.css`, `DESIGN.md §2 Colors`, `components/mobile/MoreDrawer.tsx`
-
----
-
-## Component Inventory
-
-All components listed here are from the shadcn official registry or already exist in `components/mobile/`.
-
-### shadcn components used in this phase
-
-| Component | File | Usage |
-|-----------|------|-------|
-| Card, CardHeader, CardContent, CardTitle, CardDescription | `components/ui/card.tsx` | One Card per section (Timezone, Theme, Notifications, Channels) |
-| Switch | `components/ui/switch.tsx` | Theme toggle (light/dark/system — 3-position via label row), Notification matrix toggles |
-| Select | `components/ui/select.tsx` | Theme chooser (3 values: Light / Dark / System) — straightforward, no search needed |
-| Command + Popover (Combobox pattern) | `components/ui/command.tsx` + `components/ui/popover.tsx` | Timezone chooser — `Intl.supportedValuesOf('timeZone')` list is ~600 entries; Combobox with type-to-filter required |
-| Input | `components/ui/input.tsx` | Teams webhook URL field, ntfy advanced override field |
-| Label | `components/ui/label.tsx` | All form inputs |
-| Skeleton | `components/ui/skeleton.tsx` | Loading state for Notifications matrix while event keys load |
-| Separator | `components/ui/separator.tsx` | Between sections within a Card where no `divide-y` border suffices |
-
-### New mobile components (create in `components/mobile/profile/`)
-
-| Component | Purpose |
-|-----------|---------|
-| `ProfileTimezoneSection` | Card wrapping Combobox + current-time read-only display |
-| `ProfileThemeSection` | Card with 3-option theme selector (Light / Dark / System) |
-| `ProfileNotificationMatrix` | Card with per-event-key × per-channel-type toggle grid; skeleton on load |
-| `ProfileChannelsSection` | Card with Teams input + ntfy mint/display + QR code area + test-send result |
-| `ProfileSectionSkeleton` | Generic pulsing skeleton for a Card section (reuse across all four sections) |
-
-### Existing components modified in this phase
-
-| Component | Change |
-|-----------|--------|
-| `components/mobile/MoreDrawer.tsx` | Account section: user identity row becomes a `Link` to `/mobile/profile`; "Profile & preferences" label added above Sign-out (D-25) |
-| `components/theme-toggle.tsx` | `setTheme()` callback also calls `PUT /api/me/theme` (D-20) |
-
----
-
-## Page Layout Contract
-
-### `/mobile/profile`
-
-**Focal point:** The page H1 "Profile & Preferences" is the entry focal point. The Channels Card is the conversion-weight section — it is the only Card where saving data changes external integrations, so it receives the most prescriptive layout and copywriting treatment in this contract.
-
-```
-
- Profile & Preferences
-
-
-
-```
-
-- No sub-routes, no tabs. Single scrollable page.
-- Each Card: `py-0 shadow-none` with `CardHeader` (`px-4 pt-4 pb-0`) + `CardContent` (`px-4 py-4`).
-- Page is a `'use client'` component (auth check delegated to route-level `requireAuth()` on an outer server component shell).
-- Max-width: inherits `max-w-lg mx-auto` from `app/mobile/layout.tsx` — no page-level override.
-
-### Section: Timezone Card
-
-```
-CardHeader: "Timezone" (CardTitle text-xl font-semibold)
-CardContent:
- - Combobox spanning full width, value = current IANA timezone
- - Below: read-only "Your current time: {time} in {zone}"
- - text-xs text-muted-foreground, time formatted via useUserTimezone()
- - time value uses .num utility (IBM Plex Mono)
- - On selection change: debounced 400ms → PUT /api/me/timezone → sonner toast
-```
-
-### Section: Theme Card
-
-```
-CardHeader: "Theme" (CardTitle)
-CardContent: three tappable rows, each min-h-[44px], radio-group semantics
- Row: [Icon] Light [radio/check indicator]
- Row: [Icon] Dark [radio/check indicator]
- Row: [Icon] System [radio/check indicator] (default)
- - Icons: Sun, Moon, Monitor (from lucide-react)
- - Active row: text-primary, indicator visible
- - On select: immediate → PUT /api/me/theme + setTheme() → sonner toast
-```
-
-Note: A 3-option radio group is preferred over a Switch because "System" is a third state. Use visual row selection (not a Switch). Alternatively, a `Select` with three values is acceptable if space is a concern.
-
-### Section: Notifications Card
-
-```
-CardHeader: "Notifications" (CardTitle)
- "Choose which events trigger a personal notification." (CardDescription, text-sm)
-CardContent:
- - Loading: (3 pulsing rows)
- - Empty (no personal channels configured): inline notice
- "Configure a Teams or ntfy channel below to enable personal notifications."
- text-sm text-muted-foreground, no skeleton
- - Loaded: table/grid layout
- Header row: blank | [channel-type columns, e.g. "Teams" "ntfy"]
- Per event-key row: [display_label] | [Switch per channel-type]
- - Switch saves on change (debounced 400ms) → PUT /api/me/notification-subscriptions
- - Rows sorted by notify_event_keys.sort_order ASC, then key ASC
- - Rows with is_active = false not shown
- - Row height: min-h-[44px] via py-3
- - Column widths: label flex-1, each channel column w-16 text-center
- - When only one channel type is configured: collapse to single column, no header row
-```
-
-### Section: Channels Card
-
-```
-CardHeader: "Personal Channels" (CardTitle)
- "Receive notifications directly on your devices." (CardDescription)
-CardContent: two sub-sections separated by a Separator
-
-Sub-section: Teams
- Label: "Microsoft Teams webhook URL"
- Input: full-width, placeholder "https://yourorg.webhook.office.com/..."
- Below input: test-send result (icon + short message, text-xs)
- Button row: [Save Teams URL] [Clear] — both min-h-[44px]
- Save: on click → PUT /api/me/channels/teams → test-send → show result inline → sonner toast
-
-Sub-section: ntfy (mobile push)
- State A — no topic yet:
- Label: "Mobile push (ntfy)"
- Description: "Pulse will generate a private topic for you." text-sm text-muted-foreground
- Button: [Enable mobile push] — primary bg, full width, min-h-[44px]
- On click → PUT /api/me/channels/ntfy → API mints topic → show State B
-
- State B — topic minted:
- Label: "Mobile push (ntfy)"
- Subscribe link: "https://ntfy.sh/{topic}" — text-primary, tappable, opens in new tab
- QR code: 200×200px generated client-side (qrcode.react or equivalent) for the subscribe URL
- Below QR: "Scan with the ntfy app to subscribe." text-xs text-muted-foreground
- Below link: test-send result (same pattern as Teams)
- Disclosure: "Edit advanced ▶" — expands to show custom topic override Input + "Save custom topic" button
- Button: [Test now] [Remove] — both min-h-[44px], Remove uses text-destructive
-
- Save model: no-global-save; each sub-section saves independently
- Test result display: inline row below input — icon (CheckCircle or XCircle, lucide) + short message, text-xs
-```
-
----
-
-## Copywriting Contract
-
-### Page-level
-
-| Element | Copy |
-|---------|------|
-| Page H1 | "Profile & Preferences" |
-| More Drawer link label | "Profile & preferences" (lowercase 'p' for 'preferences' — matches pattern of other drawer items) |
-
-### Timezone section
-
-| Element | Copy |
-|---------|------|
-| Card title | "Timezone" |
-| Combobox placeholder | "Search timezones…" |
-| Current time label | "Your current time: {time} in {zone}" |
-| Save toast (success) | "Timezone updated" |
-| Save toast (error) | "Failed to update timezone" |
-| Inline error | "Couldn't save. Try again." |
-
-### Theme section
-
-| Element | Copy |
-|---------|------|
-| Card title | "Theme" |
-| Option labels | "Light", "Dark", "System" |
-| Save toast (success) | "Theme updated" |
-| Save toast (error) | "Failed to update theme" |
-
-### Notifications section
-
-| Element | Copy |
-|---------|------|
-| Card title | "Notifications" |
-| Card description | "Choose which events trigger a personal notification." |
-| Empty state (no channels) | "Configure a Teams or ntfy channel below to enable personal notifications." |
-| Loading state | (skeleton — no copy) |
-| Toggle save toast (success) | "Preference saved" |
-| Toggle save toast (error) | "Couldn't save preference" |
-
-### Channels section
-
-| Element | Copy |
-|---------|------|
-| Card title | "Personal Channels" |
-| Card description | "Receive notifications directly on your devices." |
-| Teams input label | "Microsoft Teams webhook URL" |
-| Teams input placeholder | "https://yourorg.webhook.office.com/…" |
-| Teams save button | "Save Teams URL" |
-| Teams clear button | "Clear" |
-| Teams test success | "Channel verified" |
-| Teams test failure | "Test failed — {HTTP status or error message}" |
-| ntfy enable button | "Enable mobile push" |
-| ntfy description (pre-enable) | "Pulse will generate a private topic for you." |
-| ntfy subscribe hint | "Scan with the ntfy app to subscribe." |
-| ntfy test button | "Test now" |
-| ntfy remove button | "Remove" |
-| ntfy advanced disclosure | "Edit advanced" |
-| ntfy custom topic label | "Custom ntfy topic" |
-| ntfy custom topic save button | "Save custom topic" |
-| Test message body (sent to channel) | "Pulse channel verified — you can ignore this message." |
-| Save toast (success) | "Channel saved" |
-| Save toast (error) | "Failed to save channel" |
-| Remove toast (success) | "Channel removed" |
-
-### Destructive actions
-
-| Action | Confirmation approach |
-|--------|----------------------|
-| Clear Teams URL | Inline "Clear" button (text only, `text-destructive`, no dialog — data is re-enterable; not irreversible). Immediate on click; sonner toast confirms. |
-| Remove ntfy channel | "Remove" button (`text-destructive`). No confirmation dialog — same reasoning as Clear. Immediately calls DELETE `/api/me/channels/ntfy`. Sonner toast: "Channel removed". If user removes by mistake, they can re-enable. |
-| Sign out (drawer) | Existing pattern: `text-destructive hover:bg-destructive/10`, no dialog, immediate `signOut()`. No change to this in Phase 9. |
-
-No confirmation dialogs required in this phase — all destructive actions are reversible (re-enter URL / re-mint ntfy topic / sign back in).
-
----
-
-## Interaction & State Contracts
-
-### Save model (PROF-03)
-
-| Input type | Save trigger | Debounce | Optimistic update |
-|------------|-------------|----------|-------------------|
-| Timezone Combobox | On selection change | 400ms | No — update only after server confirms |
-| Theme row select | Immediately on selection | None | Yes for `setTheme()` (local render); server write is fire-and-forget with error toast |
-| Notification matrix Switch | On toggle | 400ms | No — revert on error |
-| Teams URL Input | On blur OR explicit "Save Teams URL" click | None | No |
-| ntfy custom topic Input | On explicit "Save custom topic" click | None | No |
-
-### Error model
-
-- Server errors render **inline**, not as blocking dialogs.
-- Inline errors: `text-xs text-destructive` immediately below the relevant input, cleared on next successful save.
-- Network errors: `toast.error(...)` via sonner.
-- No optimistic updates except theme `setTheme()` local-render (to avoid FOUC between click and server confirm).
-
-### Loading/skeleton states
-
-- Initial page load: each of the four Cards renders ` ` (3 rows of pulsing Skeleton bars) until its data fetch resolves.
-- Notification matrix specifically: skeleton renders while `/api/me/notification-subscriptions` loads; replaces with matrix rows on success.
-- Channel section: skeleton on initial load until `/api/me/channels` resolves.
-- Timezone Combobox: populated synchronously from `Intl.supportedValuesOf('timeZone')` — no async load needed.
-
-### Accessibility
-
-- All interactive rows and buttons: `min-h-[44px]` (iOS/Android touch target minimum).
-- Switch components: rendered with `` associated via `htmlFor`. Each toggle row includes the event label as the accessible name.
-- Combobox: shadcn `Command` with keyboard navigation; search ` ` inside is the first focusable element.
-- Theme radio group: keyboard arrow-key navigation using proper `role="radiogroup"` / `role="radio"` semantics (shadcn `RadioGroup` if available, or equivalent `aria-checked` attributes).
-- QR code image: `alt="Subscribe to {topic} on ntfy"`.
-
----
-
-## Registry Safety
-
-| Registry | Blocks Used | Safety Gate |
-|----------|-------------|-------------|
-| shadcn official | Card, Switch, Select, Command, Popover, Input, Label, Skeleton, Separator, Sheet, Button | not required |
-| Third-party | none | not applicable |
-
-QR code generation: if `qrcode.react` is introduced, it must be reviewed as a new dependency (standard npm install, not a shadcn registry entry — outside this gate's scope, but the planner should add it to `package.json` only after confirming it has no network-call side effects at render time).
-
----
-
-## Checker Sign-Off
-
-- [ ] Dimension 1 Copywriting: PASS
-- [ ] Dimension 2 Visuals: PASS
-- [ ] Dimension 3 Color: PASS
-- [ ] Dimension 4 Typography: PASS
-- [ ] Dimension 5 Spacing: PASS
-- [ ] Dimension 6 Registry Safety: PASS
-
-**Approval:** pending
diff --git a/.planning/phases/09-user-profile-preferences-new/09-VERIFICATION.md b/.planning/phases/09-user-profile-preferences-new/09-VERIFICATION.md
deleted file mode 100644
index f17b89a..0000000
--- a/.planning/phases/09-user-profile-preferences-new/09-VERIFICATION.md
+++ /dev/null
@@ -1,257 +0,0 @@
----
-phase: 09-user-profile-preferences-new
-verified: 2026-05-10T07:55:00Z
-status: human_needed
-score: 28/28 must-haves verified
-re_verification: false
-human_verification:
- - test: "Navigate to /mobile/profile while signed in — confirm four Cards render in order (Timezone, Theme, Notifications, Channels) with no console errors"
- expected: "Timezone Combobox loads the current saved timezone; Theme card shows the active theme with correct radio selection; Notifications card shows either the matrix or the empty-state message; Channels card shows the configured state (Teams URL input pre-filled or blank; ntfy either mint-ready or showing QR + subscribe link)"
- why_human: "Page requires an active Better Auth session and a running database. Cannot verify rendered UI state or fetch results programmatically."
- - test: "Open the More drawer Account section — confirm identity row is a tappable link to /mobile/profile and 'Profile & preferences' row is visible above Sign-out"
- expected: "Tapping the identity row OR the 'Profile & preferences' row navigates to /mobile/profile; Sign-out button is still present with destructive styling"
- why_human: "Mobile drawer interaction requires a running browser + session."
- - test: "On /mobile/profile Timezone section, change the timezone via the Combobox — confirm debounced PUT fires, toast appears, and the 'Your current time' line updates"
- expected: "400ms after selection, PUT /api/me/timezone succeeds; sonner toast shows 'Timezone updated'; current-time line formats with new zone"
- why_human: "Requires an active session + database write. Debounce timing is runtime behavior."
- - test: "On /mobile/profile Theme section, tap 'Dark' — confirm setTheme fires immediately and PUT /api/me/theme is called"
- expected: "Page theme switches immediately (optimistic); server write persists; reloading the page in a new session should still show Dark theme"
- why_human: "next-themes + session bridge behavior requires a live browser."
- - test: "On /mobile/profile Channels section, configure a Teams webhook URL with an invalid host (e.g. https://evil.com) — confirm 400 inline error renders under the input"
- expected: "Error message 'webhook_url must be https://*.webhook.office.com or https://*.logic.azure.com' appears as text-xs text-destructive below the input; no toast of type 'Channel saved'"
- why_human: "Requires live browser rendering of React state."
- - test: "Open 'Edit advanced' in the ntfy sub-section, enter a topic with spaces — confirm 400 inline error renders"
- expected: "Custom topic 'bad space' triggers PUT /api/me/channels/ntfy returning 400; inline error shows 'topic must match ^[A-Za-z0-9_-]{6,64}$' below the custom-topic Input"
- why_human: "Requires live browser + session."
- - test: "On desktop, change theme via the ThemeToggle (Light/Dark/System dropdown) — confirm it writes through to /api/me/theme"
- expected: "Network tab shows PUT /api/me/theme with the correct body; opening /mobile/profile on a different session shows the same theme"
- why_human: "Cross-device persistence requires two browser sessions."
- - test: "In /admin/workflow/channels (admin session), confirm Owner column shows 'Global' for global rows and 'Personal: {email}' for personal rows; filter widget toggles between All / Global only / Personal only"
- expected: "Owner badges render correctly; selecting 'Personal only' hides global rows; selecting 'Global only' hides personal rows"
- why_human: "Requires admin role session + channels data in database."
- - test: "In /admin/workflow/executions, toggle 'Show only fallbacks' — confirm only executions with user_route_fallback steps appear"
- expected: "Switch appends fallbacks_only=1 to the fetch URL; rows show a 'fallback' badge when applicable; empty state message when no fallbacks exist"
- why_human: "Requires pipeline execution data in database with user_route_fallback output."
----
-
-# Phase 9: User Profile & Preferences Verification Report
-
-**Phase Goal:** A logged-in user reaches a profile/settings page from the More drawer and can configure timezone (chooser UI), theme (light/dark/system, persisted server-side for cross-device consistency), mobile push notifications (per-event toggles, delivered via the ntfy phone app), and personal notification channels (Teams webhook URL, Pulse-minted ntfy topic). Changes persist per-user and the existing notify pipeline routes through these per-user channels for events the user is subscribed to.
-**Verified:** 2026-05-10T07:55:00Z
-**Status:** human_needed
-**Re-verification:** No — initial verification
-
-## Goal Achievement
-
-### Observable Truths
-
-| # | Truth | Status | Evidence |
-|---|-------|--------|----------|
-| 1 | user table has theme TEXT column with default 'system'; existing rows backfilled | ✓ VERIFIED | `migrations/084_add_user_theme.sql` line 13: `ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'`; line 17: `UPDATE "user" SET theme = 'system' WHERE theme IS NULL` |
-| 2 | session.user.theme exposed via Better Auth additionalFields | ✓ VERIFIED | `lib/auth.ts` lines 99-101: `theme: { type: "string", defaultValue: "system" }` in additionalFields block alongside role, requires_setup, timezone |
-| 3 | notification_channels has owner_user_id + partial unique index | ✓ VERIFIED | `migrations/085_personal_notification_channels.sql` lines 18-28: ALTER TABLE adds column with FK cascade; unique index `notification_channels_owner_user_id_channel_type_uniq` WHERE owner_user_id IS NOT NULL |
-| 4 | notify_event_keys and user_event_subscriptions tables exist | ✓ VERIFIED | `migrations/086_notify_event_keys_and_subscriptions.sql` lines 13 and 26; composite PK (user_id, event_key, channel_type) confirmed; seed row present |
-| 5 | lib/types/pipeline.ts exports NotifyEventKey, UserEventSubscription, RouteToUser, UserRouteFallback | ✓ VERIFIED | Lines 47, 178, 188, 210, 225, 241 in pipeline.ts |
-| 6 | GET/PUT /api/me/theme authenticated, validated, uses updated_at (not "updatedAt") | ✓ VERIFIED | `app/api/me/theme/route.ts`: requireAuth on both handlers; ALLOWED_THEMES set; UPDATE uses `updated_at = NOW()`; no "updatedAt" found |
-| 7 | lib/services/personal-channels.ts exports all 7 required symbols | ✓ VERIFIED | Lines 17, 32, 47, 56, 73, 130, 132: TEST_MESSAGE_BODY, isValidTeamsWebhookUrl, isValidNtfyTopic, mintNtfyTopic, sendChannelTest, PERSONAL_CHANNEL_TYPES, isPersonalChannelType. TEST_MESSAGE_BODY = 'Pulse channel verified — you can ignore this message.' |
-| 8 | GET /api/me/channels scoped to owner_user_id = session.user.id | ✓ VERIFIED | `app/api/me/channels/route.ts` line: `WHERE owner_user_id = $1` with session.user.id |
-| 9 | PUT/DELETE /api/me/channels/[type] UPSERT keyed by (owner_user_id, channel_type) | ✓ VERIFIED | `app/api/me/channels/[type]/route.ts`: WITH-CTE UPSERT at line 90+; DELETE at line 163 scoped to owner_user_id = $1 AND channel_type = $2 |
-| 10 | POST /api/me/channels/[type]/test calls sendChannelTest | ✓ VERIFIED | `app/api/me/channels/[type]/test/route.ts` line 44: `sendChannelTest(channel)` |
-| 11 | GET/PUT /api/me/notification-subscriptions with matrix + ON CONFLICT UPSERT | ✓ VERIFIED | `app/api/me/notification-subscriptions/route.ts`: three queries in GET; matrix defaulted to true (line 118); ON CONFLICT (user_id, event_key, channel_type) in PUT; isPersonalChannelType validation |
-| 12 | notify-resolvers.ts has 3 v1 resolvers in Map registry | ✓ VERIFIED | `lib/services/pipeline-steps/notify-resolvers.ts` lines 69-72: RESOLVERS Map with direct_email, pulse_user_id, autotask_resource_email |
-| 13 | notify.ts backward compatible — no route_to_user = original behavior | ✓ VERIFIED | Line 55: `if (!route) { return await dispatchToGlobalChannel(channelId, step.config, message); }` |
-| 14 | notify.ts mute path returns success:true+notified:false, no fallback, no send | ✓ VERIFIED | Lines 173-182: `if (!enabled) { return { success: true, output: { notified: false, skipped_reason: 'user_muted', ... } }; }` — no fallbackToGlobal call on this branch |
-| 15 | notify.ts fallback records user_route_fallback in output | ✓ VERIFIED | fallbackToGlobal() at lines 253-275 annotates output with user_route_fallback; all 5 fallback reasons present |
-| 16 | notify.test.ts 'muted user must not fall back' passes | ✓ VERIFIED | `npx vitest run lib/services/pipeline-steps/notify.test.ts` → 1 passed, 0 failed |
-| 17 | /mobile/profile page — requireAuth + redirect, 4 sections in order | ✓ VERIFIED | `app/mobile/profile/page.tsx`: requireAuth called; `if (error || !session) redirect('/auth/sign-in')`; no `return error`; four sections in exact order: Timezone, Theme, Notifications, Channels |
-| 18 | MoreDrawer Account section gains Profile & preferences link | ✓ VERIFIED | `components/mobile/MoreDrawer.tsx` lines 136 and 156: two Link elements to /mobile/profile; line 158: 'Profile & preferences' text |
-| 19 | ProfileTimezoneSection — Combobox from Intl.supportedValuesOf + PUT + debounce | ✓ VERIFIED | `ProfileTimezoneSection.tsx`: Intl.supportedValuesOf('timeZone'); EXTRA_ALLOWED_TIMEZONES; PUT /api/me/timezone; setTimeout 400ms; 'Search timezones…'; 'Your current time:' |
-| 20 | ProfileThemeSection — 3-radio rows + setTheme + PUT + rollback | ✓ VERIFIED | `ProfileThemeSection.tsx`: useTheme; radiogroup; setTheme for all 3 values; PUT /api/me/theme; rollback on error; toast.success + toast.error |
-| 21 | ProfileNotificationMatrix — matrix + default-enabled + debounced PUT | ✓ VERIFIED | `ProfileNotificationMatrix.tsx`: GET /api/me/notification-subscriptions; ?? true default; 400ms debounce; empty-state copy; Skeleton loading |
-| 22 | ProfileChannelsSection — Teams + ntfy with QR, inline errors, qrcode.react installed | ✓ VERIFIED | `ProfileChannelsSection.tsx`: QRCodeSVG from qrcode.react; Microsoft Teams webhook URL label; Mobile push (ntfy); Enable mobile push; Edit advanced; customTopicError state; teamsError inline; node_modules/qrcode.react exists; package.json pin ^4.2.0 |
-| 23 | ProfileChannelsSectionPlaceholder deleted; page imports real component | ✓ VERIFIED | File does not exist; page.tsx imports from `@/components/mobile/profile/ProfileChannelsSection` |
-| 24 | ThemeSessionBridge compares session.user.theme to useTheme() + setTheme on mismatch | ✓ VERIFIED | `ThemeSessionBridge.tsx`: useSession + useTheme; allowlist guard (light/dark/system); setTheme(serverTheme) in useEffect; mounted in app/layout.tsx inside AuthProvider |
-| 25 | ThemeToggle writes through to PUT /api/me/theme | ✓ VERIFIED | `components/theme-toggle.tsx`: writeTheme helper at line 21; fetch('/api/me/theme', { method: 'PUT' }); all 3 DropdownMenuItems call writeTheme |
-| 26 | /api/notification-channels now requires auth; non-admins see global-only | ✓ VERIFIED | `app/api/notification-channels/route.ts`: requireAuth() in GET; admin branch with LEFT JOIN user email; non-admin branch WHERE owner_user_id IS NULL; requireAdmin() in POST |
-| 27 | /admin/workflow/event-keys CRUD page + API exist | ✓ VERIFIED | `app/admin/workflow/event-keys/page.tsx` fetches /api/admin/notify-event-keys; GET+POST+PUT+DELETE routes exist with requireAdmin(); key regex validation |
-| 28 | /admin/workflow/executions page with fallback filter + /api/admin/pipeline-executions | ✓ VERIFIED | `app/admin/workflow/executions/page.tsx`: 'Show only fallbacks' Switch; fallbacks_only=1 appended; `app/api/admin/pipeline-executions/route.ts`: requireAdmin(); 4 complete parameterized SQL strings; output_data ? 'user_route_fallback'; no alias-in-WHERE bug |
-
-**Score:** 28/28 truths verified
-
-### Required Artifacts
-
-| Artifact | Provides | Status | Details |
-|----------|----------|--------|---------|
-| `migrations/084_add_user_theme.sql` | theme column on user with backfill | ✓ VERIFIED | ALTER TABLE "user" ADD COLUMN + backfill + COMMENT |
-| `migrations/085_personal_notification_channels.sql` | owner_user_id + partial unique index | ✓ VERIFIED | ADD COLUMN + idx + unique index WHERE owner_user_id IS NOT NULL |
-| `migrations/086_notify_event_keys_and_subscriptions.sql` | notify_event_keys + user_event_subscriptions tables | ✓ VERIFIED | Both tables with correct PKs; seed row with ON CONFLICT DO NOTHING |
-| `lib/auth.ts` | theme additionalField | ✓ VERIFIED | theme: { type: "string", defaultValue: "system" } present |
-| `lib/types/pipeline.ts` | NotificationChannel.owner_user_id, NotifyEventKey, UserEventSubscription, RouteToUser, UserRouteFallback | ✓ VERIFIED | All interfaces/types present |
-| `app/api/me/theme/route.ts` | GET + PUT theme | ✓ VERIFIED | Both handlers; allowlist; updated_at; requireAuth |
-| `app/api/me/channels/route.ts` | GET user personal channels | ✓ VERIFIED | Scoped to owner_user_id = session.user.id |
-| `app/api/me/channels/[type]/route.ts` | PUT (upsert) + DELETE | ✓ VERIFIED | WITH-CTE UPSERT; DELETE scoped; validation |
-| `app/api/me/channels/[type]/test/route.ts` | POST test send | ✓ VERIFIED | sendChannelTest + requireAuth |
-| `app/api/me/notification-subscriptions/route.ts` | GET matrix + PUT single row | ✓ VERIFIED | 3-query GET; ON CONFLICT UPSERT; isPersonalChannelType |
-| `lib/services/personal-channels.ts` | 7 required exports + sendChannelTest | ✓ VERIFIED | All 7 symbols; correct test message body |
-| `lib/services/pipeline-steps/notify-resolvers.ts` | 3 resolvers + Map registry | ✓ VERIFIED | RESOLVERS Map; resolveRecipient; registerResolver |
-| `lib/services/pipeline-steps/notify.ts` | route_to_user branch + fallback semantics | ✓ VERIFIED | Backward compat; mute; fallback; all 5 reasons; ['ntfy','teams'] default order; _INTERNALS test seam |
-| `lib/services/pipeline-steps/notify.test.ts` | Vitest mute assertion | ✓ VERIFIED | Test passes; asserts no fallback, no notification_channels query, no fetch |
-| `app/mobile/profile/page.tsx` | /mobile/profile page shell | ✓ VERIFIED | requireAuth + redirect; 4 sections; correct H1 |
-| `components/mobile/profile/ProfileSectionSkeleton.tsx` | Skeleton | ✓ VERIFIED | Card + Skeleton primitives; 3 rows |
-| `components/mobile/profile/ProfileTimezoneSection.tsx` | Timezone Combobox | ✓ VERIFIED | Intl.supportedValuesOf; EXTRA_ALLOWED_TIMEZONES; debounce 400ms; current time |
-| `components/mobile/profile/ProfileThemeSection.tsx` | Theme radio rows | ✓ VERIFIED | radiogroup; 3 setTheme calls; PUT /api/me/theme; rollback |
-| `components/mobile/profile/ProfileNotificationMatrix.tsx` | Notifications matrix | ✓ VERIFIED | matrix ?? true default; debounce; empty state; Skeleton |
-| `components/mobile/profile/ProfileChannelsSection.tsx` | Channels (Teams + ntfy + QR) | ✓ VERIFIED | QRCodeSVG; all required literals; inline errors; test-send result |
-| `components/mobile/profile/ThemeSessionBridge.tsx` | Theme session bridge | ✓ VERIFIED | useSession + useTheme; allowlist; setTheme; returns null |
-| `components/theme-toggle.tsx` | ThemeToggle write-through | ✓ VERIFIED | writeTheme helper; PUT /api/me/theme; 3 DropdownMenuItems updated |
-| `app/layout.tsx` | ThemeSessionBridge mounted | ✓ VERIFIED | Import + ` ` inside AuthProvider |
-| `app/admin/workflow/channels/page.tsx` | Owner column + filter | ✓ VERIFIED | Owner badge (Global / Personal: email); Select filter; disclaimer |
-| `app/api/notification-channels/route.ts` | Auth-gated GET + POST | ✓ VERIFIED | requireAuth GET; requireAdmin POST; all 4 channel_type values accepted |
-| `app/api/notification-channels/[id]/route.ts` | Per-row auth | ✓ VERIFIED | requireAuth; isOwner OR isAdmin predicate |
-| `app/admin/workflow/event-keys/page.tsx` | Event keys CRUD | ✓ VERIFIED | List + new key form; all CRUD via /api/admin/notify-event-keys |
-| `app/api/admin/notify-event-keys/route.ts` | GET+POST event keys | ✓ VERIFIED | requireAdmin; SELECT from notify_event_keys; key regex validation |
-| `app/api/admin/notify-event-keys/[key]/route.ts` | PUT+DELETE event key | ✓ VERIFIED | requireAdmin; UPDATE COALESCE; DELETE returning key |
-| `app/admin/workflow/executions/page.tsx` | Fallback filter page | ✓ VERIFIED | 'Show only fallbacks' Switch; fallbacks_only=1 param; has_fallback badge |
-| `app/api/admin/pipeline-executions/route.ts` | Pipeline executions API | ✓ VERIFIED | requireAdmin; 4 parameterized SQL variants; output_data ? 'user_route_fallback'; no alias-in-WHERE |
-
-### Key Link Verification
-
-| From | To | Via | Status | Details |
-|------|----|-----|--------|---------|
-| migrations/084_add_user_theme.sql | user table | ALTER TABLE ADD COLUMN theme DEFAULT 'system' | ✓ WIRED | Confirmed in migration file |
-| lib/auth.ts additionalFields | session.user.theme | theme: { type: "string", defaultValue: "system" } | ✓ WIRED | Lines 99-101 |
-| PUT /api/me/channels/teams | notification_channels | WITH-CTE UPSERT WHERE owner_user_id = $1 AND channel_type = $2 | ✓ WIRED | UPSERT confirmed at line 90+ of [type]/route.ts |
-| PUT /api/me/channels/ntfy | mintNtfyTopic() | crypto.randomUUID() → pulse- prefix | ✓ WIRED | mintNtfyTopic import + usage in [type]/route.ts |
-| GET /api/me/notification-subscriptions | notify_event_keys + user_event_subscriptions | LEFT JOIN with default-enabled fallback | ✓ WIRED | 3 queries confirmed; matrix ?? true |
-| notify.ts | user_event_subscriptions | SELECT enabled WHERE user_id, event_key, channel_type | ✓ WIRED | Lines 162-170 |
-| notify.ts | notification_channels personal lookup | WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true | ✓ WIRED | Lines 187-193 |
-| notify.ts | notify-resolvers.ts | import { resolveRecipient } | ✓ WIRED | Line 22 |
-| notify.test.ts | notify.ts dispatchUserRoute | via _INTERNALS test seam | ✓ WIRED | _INTERNALS.dispatchUserRoute used in test |
-| /admin/workflow/channels | notification_channels + user.email | LEFT JOIN "user" ON owner_user_id = user.id | ✓ WIRED | Lines 30-32 of notification-channels/route.ts |
-| /admin/workflow/event-keys | notify_event_keys | POST/PUT/DELETE via /api/admin/notify-event-keys | ✓ WIRED | All CRUD routes verified |
-| /admin/workflow/executions | pipeline_executions + output_data->'user_route_fallback' | GET /api/admin/pipeline-executions?fallbacks_only=1 | ✓ WIRED | EXISTS predicate on output_data confirmed |
-| ProfileChannelsSection | /api/me/channels + ntfy/test | fetch with PUT/DELETE/POST | ✓ WIRED | All fetch calls to /api/me/channels/* confirmed |
-| ThemeSessionBridge | next-themes useTheme | setTheme(session.user.theme) on mismatch | ✓ WIRED | useEffect with allowlist guard |
-| ThemeToggle | /api/me/theme PUT | writeTheme helper | ✓ WIRED | fetch in writeTheme; all 3 items call it |
-
-### Data-Flow Trace (Level 4)
-
-| Artifact | Data Variable | Source | Produces Real Data | Status |
-|----------|---------------|--------|--------------------|--------|
-| ProfileTimezoneSection | selectedZone | GET /api/me/timezone → SELECT theme FROM "user" | Yes — DB query in route | ✓ FLOWING |
-| ProfileThemeSection | theme (useTheme) | GET /api/me/theme → SELECT theme FROM "user" | Yes — DB query in route | ✓ FLOWING |
-| ProfileNotificationMatrix | data.matrix | GET /api/me/notification-subscriptions → 3 DB queries | Yes — notify_event_keys + user_event_subscriptions | ✓ FLOWING |
-| ProfileChannelsSection | channels.teams/ntfy | GET /api/me/channels → notification_channels WHERE owner_user_id | Yes — DB query | ✓ FLOWING |
-| /admin/workflow/channels | channel rows | /api/notification-channels → LEFT JOIN user | Yes — DB query with JOIN | ✓ FLOWING |
-| /admin/workflow/executions | executions | /api/admin/pipeline-executions → pipeline_executions + EXISTS subquery | Yes — DB query | ✓ FLOWING |
-
-### Behavioral Spot-Checks
-
-| Behavior | Command | Result | Status |
-|----------|---------|--------|--------|
-| notify.test.ts muted user | `npx vitest run lib/services/pipeline-steps/notify.test.ts` | 1 passed (1) | ✓ PASS |
-| TypeScript compiles cleanly | `npx tsc --noEmit --pretty` | No output (exit 0) | ✓ PASS |
-| Full test suite — no regressions | `npm test` | 185 tests: 2 failed (pre-existing itglue-search.test.ts failures from before Phase 09); 183 passed | ✓ PASS |
-| personal-channels.ts exports | grep for 7 exports | All 7 symbols found | ✓ PASS |
-| notify.ts fallback reasons | grep for all 5 | no_channel, send_failed, user_not_found, no_field_value, resolver_unknown all present | ✓ PASS |
-| alias-in-WHERE bug absent | `grep -c "AND has_fallback" pipeline-executions/route.ts` | 0 (no occurrence) | ✓ PASS |
-
-### Requirements Coverage
-
-| Requirement | Source Plan | Description | Status | Evidence |
-|-------------|------------|-------------|--------|----------|
-| PROF-01 | 09-04 | /mobile/profile exists, gated by requireAuth, accessible from More drawer | ✓ SATISFIED | page.tsx + MoreDrawer link verified |
-| PROF-02 | 09-04 | Four sections: Timezone, Theme, Notifications, Channels | ✓ SATISFIED | 4 components imported and rendered in order |
-| PROF-03 | 09-04 | Per-section save model: debounced 400ms; toasts; inline errors | ✓ SATISFIED | setTimeout 400ms in both Timezone + Matrix; inline errors in Channels |
-| PROF-04 | 09-04 | MoreDrawer 'Profile & preferences' link above Sign-out | ✓ SATISFIED | Two Link elements to /mobile/profile; signOut preserved |
-| TZ-CHOOSER-01 | 09-04 | Combobox from Intl.supportedValuesOf + EXTRA_ALLOWED_TIMEZONES | ✓ SATISFIED | Both present in ProfileTimezoneSection |
-| TZ-CHOOSER-02 | 09-04 | Currently-rendering time shown alongside picker | ✓ SATISFIED | 'Your current time:' in ProfileTimezoneSection |
-| THEME-01 | 09-01 | theme column on user table, default 'system', backfill | ✓ SATISFIED | migration/084 verified |
-| THEME-02 | 09-02 | GET/PUT /api/me/theme authenticated, validated | ✓ SATISFIED | theme/route.ts verified |
-| THEME-03 | 09-04 | ThemeSessionBridge applies server theme on session load | ✓ SATISFIED | ThemeSessionBridge.tsx verified, mounted in layout |
-| THEME-04 | 09-05 | Desktop ThemeToggle writes through to PUT /api/me/theme | ✓ SATISFIED | writeTheme helper in theme-toggle.tsx |
-| THEME-05 | 09-01 | Default 'system' for new and existing users | ✓ SATISFIED | DEFAULT 'system' + backfill in migration |
-| CHAN-01 | 09-01 | owner_user_id column added to notification_channels | ✓ SATISFIED | migration/085 verified |
-| CHAN-02 | 09-02 | At most one personal Teams + one ntfy per user (API UPSERT) | ✓ SATISFIED | WITH-CTE UPSERT keyed by (owner_user_id, channel_type); partial unique index also present as defense-in-depth per plan decision |
-| CHAN-03 | 09-02, 09-05 | Pulse-minted ntfy topic; subscribe link + QR; custom topic override | ✓ SATISFIED | mintNtfyTopic; QRCodeSVG; Edit advanced disclosure |
-| CHAN-04 | 09-02 | Teams URL validated against *.webhook.office.com / *.logic.azure.com | ✓ SATISFIED | isValidTeamsWebhookUrl in personal-channels.ts |
-| CHAN-05 | 09-02, 09-05 | Best-effort test send on save; result inline | ✓ SATISFIED | sendChannelTest called in PUT [type]/route.ts; inline test result in ProfileChannelsSection |
-| CHAN-06 | 09-06 | Admins can read/edit any personal channel in /admin/workflow/channels | ✓ SATISFIED | Owner column, filter, per-row auth in notification-channels routes |
-| CHAN-07 | 09-02 | /api/me/channels GET/PUT/DELETE/POST test — all routes auth-gated | ✓ SATISFIED | All 4 route variants verified |
-| SUB-01 | 09-01, 09-06 | notify_event_keys table + admin CRUD at /admin/workflow/event-keys | ✓ SATISFIED | Table in migration/086; CRUD routes + page verified |
-| SUB-02 | 09-01 | user_event_subscriptions table with composite PK | ✓ SATISFIED | Table in migration/086 with PRIMARY KEY (user_id, event_key, channel_type) |
-| SUB-03 | 09-04 | Notifications matrix in profile: rows = event keys, columns = channel types | ✓ SATISFIED | ProfileNotificationMatrix renders matrix |
-| SUB-04 | 09-02 | /api/me/notification-subscriptions GET matrix + PUT single row | ✓ SATISFIED | route.ts verified with default-enabled fallback |
-| ROUTE-01 | 09-03 | route_to_user block on notify step config; backward compat when absent | ✓ SATISFIED | notify.ts if(!route) early return |
-| ROUTE-02 | 09-03 | 3 v1 resolvers in Map registry; adding resolver is one-file change | ✓ SATISFIED | notify-resolvers.ts RESOLVERS Map with all 3 |
-| ROUTE-03 | 09-03 | Full resolve chain: field → resolver → user → subscription → channel → send | ✓ SATISFIED | notify.ts dispatchUserRoute implements chain in order |
-| ROUTE-04 | 09-03 | Fallback to channel_id on no-channel/send-failure; user_route_fallback recorded | ✓ SATISFIED | fallbackToGlobal() annotates output; all 5 reasons present |
-| ROUTE-05 | 09-03 | Muted user → skipped_reason='user_muted', no fallback, success:true | ✓ SATISFIED | Verified by behavioral test AND code review |
-| ROUTE-06 | 09-03 | channel_type omitted → ntfy first, then teams, then fallback | ✓ SATISFIED | attemptOrder: ['ntfy', 'teams'] when channel_type absent |
-| ROUTE-07 | 09-06 | /admin/workflow/executions 'Show only fallbacks' filter | ✓ SATISFIED | executions/page.tsx + pipeline-executions/route.ts |
-
-### Anti-Patterns Found
-
-No anti-patterns detected. Scan of all Phase 9 files returned:
-- No TODO/FIXME/PLACEHOLDER comments
-- No stub return patterns (empty arrays, null returns in user-visible paths)
-- ProfileChannelsSectionPlaceholder.tsx deleted as planned
-- All fetch calls have response handling
-
-### Human Verification Required
-
-**1. /mobile/profile full render (4 sections)**
-- **Test:** Sign in, navigate to `/mobile/profile` on a mobile-sized browser. Confirm four Cards render in order with no console errors.
-- **Expected:** Timezone Combobox shows saved timezone; Theme shows active selection; Notifications shows matrix or empty-state; Channels shows configured state.
-- **Why human:** Requires active Better Auth session + running database.
-
-**2. More drawer Profile & preferences link**
-- **Test:** Open the More drawer from the mobile shell. Confirm identity row is tappable link and 'Profile & preferences' row appears above Sign-out.
-- **Expected:** Both links navigate to `/mobile/profile`; Sign-out remains with destructive styling.
-- **Why human:** Mobile drawer interaction requires live browser.
-
-**3. Timezone Combobox save and debounce**
-- **Test:** Change timezone in the Combobox. Confirm 400ms delay, PUT fires, sonner toast appears, current-time line updates.
-- **Expected:** Timezone updated toast; no immediate PUT (400ms debounce enforced).
-- **Why human:** Debounce timing and toast rendering require runtime.
-
-**4. Theme radio rows + write-through + cross-device persistence**
-- **Test:** Tap 'Dark' in the Theme section. Reload in a different session.
-- **Expected:** Theme switches immediately (next-themes); PUT /api/me/theme succeeds; new session loads Dark theme.
-- **Why human:** Cross-device session sync requires two browser sessions.
-
-**5. Teams webhook inline error on 400**
-- **Test:** Enter `https://evil.com` in the Teams URL input and save.
-- **Expected:** Inline `text-xs text-destructive` error under the input; no 'Channel saved' toast.
-- **Why human:** Requires React state rendering in a live browser.
-
-**6. ntfy custom topic inline error on 400**
-- **Test:** Open 'Edit advanced', enter `bad space` as custom topic, save.
-- **Expected:** Inline error 'topic must match ^[A-Za-z0-9_-]{6,64}$' below Input.
-- **Why human:** Requires React state rendering in a live browser.
-
-**7. ThemeToggle desktop write-through**
-- **Test:** Use desktop ThemeToggle to switch theme. Open `/mobile/profile` in another session.
-- **Expected:** Both sessions show same theme.
-- **Why human:** Cross-device persistence requires two browser sessions.
-
-**8. Admin channels Owner column + filter**
-- **Test:** As admin, open `/admin/workflow/channels`. Confirm Owner badges; test filter widget.
-- **Expected:** Global rows show 'Global' badge; personal rows show 'Personal: email'; filter hides/shows by type.
-- **Why human:** Requires admin session + channel data.
-
-**9. Admin executions fallback filter**
-- **Test:** As admin, open `/admin/workflow/executions`. Toggle 'Show only fallbacks'.
-- **Expected:** Only rows with user_route_fallback appear; empty state message when none exist.
-- **Why human:** Requires pipeline execution data with user_route_fallback output in database.
-
-### Gaps Summary
-
-No gaps. All 28 must-haves verified across all 6 plans. TypeScript compiles cleanly (tsc --noEmit exits 0). The Phase 09 notify.test.ts passes. The 2 pre-existing failures in `itglue-search.test.ts` pre-date Phase 09 (confirmed in task brief) and are not regressions.
-
-The `status: human_needed` designation reflects 9 items that require a live browser + session + database to fully validate the UI behavior — these cannot be verified programmatically. All backend logic is fully verified at code level.
-
----
-
-_Verified: 2026-05-10T07:55:00Z_
-_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md b/.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md
deleted file mode 100644
index 8570c98..0000000
--- a/.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md
+++ /dev/null
@@ -1,437 +0,0 @@
----
-phase: 09.1-ntfy-backend-fix
-plan: "01"
-type: execute
-wave: 1
-depends_on: []
-gap_closure: true
-autonomous: true
-requirements: [CHAN-03, CHAN-05, CHAN-07, ROUTE-04]
-files_modified:
- - lib/services/personal-channels.ts
- - lib/services/pipeline-steps/notify.ts
- - lib/services/pipeline-steps/approval.ts
- - lib/services/ticket-digest-service.ts
- - components/mobile/profile/ProfileChannelsSection.tsx
-must_haves:
- truths:
- - "Personal ntfy channels mint topics with the `pulse-me-` prefix (8 hex chars of entropy)"
- - "Custom ntfy topics submitted by users via 'Edit advanced' are accepted only when they match `^pulse-me-[A-Za-z0-9-]{6,64}$` — `pulse-`, `noc-`, `soc-`, and arbitrary names are rejected"
- - "All ntfy send paths used for personal channels (sendChannelTest, notify.ts sendNtfy, approval.ts ntfy branch, ticket-digest-service.ts ntfy branch) target the company server (https://ntfy.wulfconsulting.cloud by default) and send Authorization: Bearer ${NTFY_PULSE_TOKEN} when the channel is personal"
- - "QR code and subscribe link in /mobile/profile point at the company ntfy host (NEXT_PUBLIC_NTFY_BASE_URL), not ntfy.sh"
- - "Inline error copy under the custom-topic Input reads 'Topic must start with pulse-me-' on 400"
- - "Existing global / admin ntfy rows (owner_user_id IS NULL) that set their own config.server_url / config.auth_token are still honored — only personal channels (owner_user_id IS NOT NULL) are forced to the company server + NTFY_PULSE_TOKEN"
- artifacts:
- - path: "lib/services/personal-channels.ts"
- provides: "Updated NTFY_TOPIC_RE, mintNtfyTopic, sendChannelTest forcing company server + bearer auth for personal ntfy"
- contains: "pulse-me-"
- - path: "lib/services/pipeline-steps/notify.ts"
- provides: "sendNtfy uses NTFY_BASE_URL when channel is personal; bearer auth from NTFY_PULSE_TOKEN for personal channels"
- contains: "owner_user_id"
- - path: "lib/services/pipeline-steps/approval.ts"
- provides: "ntfy approval branch uses NTFY_BASE_URL + NTFY_PULSE_TOKEN for personal channels"
- - path: "lib/services/ticket-digest-service.ts"
- provides: "deliver() ntfy branch uses NTFY_BASE_URL + NTFY_PULSE_TOKEN when channel is personal"
- - path: "components/mobile/profile/ProfileChannelsSection.tsx"
- provides: "QR + subscribe link target NEXT_PUBLIC_NTFY_BASE_URL; inline error copy updated"
- contains: "NEXT_PUBLIC_NTFY_BASE_URL"
- key_links:
- - from: "lib/services/personal-channels.ts mintNtfyTopic"
- to: "PUT /api/me/channels/ntfy"
- via: "first-save mint path returns pulse-me-XXXXXXXX"
- pattern: "pulse-me-"
- - from: "lib/services/personal-channels.ts sendChannelTest (ntfy)"
- to: "process.env.NTFY_BASE_URL + process.env.NTFY_PULSE_TOKEN"
- via: "Authorization: Bearer header on POST to {NTFY_BASE_URL}/{topic}"
- pattern: "NTFY_PULSE_TOKEN"
- - from: "components/mobile/profile/ProfileChannelsSection.tsx"
- to: "process.env.NEXT_PUBLIC_NTFY_BASE_URL"
- via: "QR `value` prop + subscribe `href`"
- pattern: "NEXT_PUBLIC_NTFY_BASE_URL"
----
-
-
-Close the major gap surfaced in `09-HUMAN-UAT.md` Test 1: personal ntfy channels currently target the public `ntfy.sh` server with a `pulse-` prefix and no enforced auth. Production runs a private ntfy instance at `https://ntfy.wulfconsulting.cloud` with bearer auth (`NTFY_PULSE_TOKEN`) and reserves `noc-*` / `soc-*` topic prefixes for NOC/SOC. Personal channels must use the namespaced `pulse-me-` prefix and the company server with the company token.
-
-Purpose: make the Phase 9 personal-channel feature actually deliverable on this Pulse deployment.
-
-Output: regex tightened, prefix changed to `pulse-me-`, all four ntfy send sites (sendChannelTest, notify.ts, approval.ts, ticket-digest-service.ts) routed at the company server with bearer auth for personal channels, and the mobile profile UI QR/subscribe-link/error-copy aligned. Existing global/admin ntfy rows continue to honor their own config.server_url + config.auth_token (out_of_scope preserved).
-
-
-
-@$HOME/.claude/get-shit-done/workflows/execute-plan.md
-@$HOME/.claude/get-shit-done/templates/summary.md
-
-
-
-@.planning/phases/09-user-profile-preferences-new/09-HUMAN-UAT.md
-@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
-@.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md
-@.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md
-@CLAUDE.md
-@lib/services/personal-channels.ts
-@lib/services/pipeline-steps/notify.ts
-@lib/services/pipeline-steps/approval.ts
-@lib/services/ticket-digest-service.ts
-@components/mobile/profile/ProfileChannelsSection.tsx
-
-
-
-
-
-Env vars (read at runtime via process.env):
-- NTFY_BASE_URL — server-side. Default 'https://ntfy.wulfconsulting.cloud'.
-- NTFY_PULSE_TOKEN — server-side. Already in .env. Used as Bearer token for personal ntfy publishes.
-- NEXT_PUBLIC_NTFY_BASE_URL — client-side (exposed by Next.js because of NEXT_PUBLIC_ prefix). Default 'https://ntfy.wulfconsulting.cloud'.
-
-NotificationChannel shape (from `lib/types/pipeline.ts` — already imported in all four touched files):
-```ts
-interface NotificationChannel {
- id: number;
- name: string;
- channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
- config: Record; // { topic, server_url?, auth_token?, ... } for ntfy
- is_active: boolean;
- owner_user_id?: string | null; // NULL for global/admin rows, set for personal rows
- // ...
-}
-```
-
-The discriminator `owner_user_id` (added in Phase 9 migration 085) is the canonical signal for "this is a personal channel" — use it directly. Do NOT introduce a separate "isPersonal" flag.
-
-
-
-
-
-
- Task 1: Update personal-channels.ts (regex + prefix + bearer auth) and propagate to the three other ntfy publish sites
- lib/services/personal-channels.ts, lib/services/pipeline-steps/notify.ts, lib/services/pipeline-steps/approval.ts, lib/services/ticket-digest-service.ts
-
-Backend-only edits. Make the four changes below. Do NOT introduce a new file — all logic lives next to its existing site. Do NOT add Zod. Do NOT touch routes, DB schema, or migrations. Do NOT write to `.env` (it is committed and may carry secrets); `NTFY_PULSE_TOKEN` is already present there.
-
-**A. `lib/services/personal-channels.ts`** — three surgical edits:
-
-1. Replace the regex constant (around line 45):
- ```ts
- const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{6,64}$/;
- ```
- with:
- ```ts
- /**
- * Personal ntfy topic format (UAT-FIX-01):
- * - MUST start with `pulse-me-` (reserved prefix for personal channels;
- * `noc-*` and `soc-*` are reserved for NOC/SOC operations).
- * - Followed by 6-64 chars from [A-Za-z0-9-] (no underscores after the
- * prefix — keeps topics clean for URL display).
- * Custom topics submitted via the /mobile/profile "Edit advanced" disclosure
- * must satisfy this regex; minted topics (mintNtfyTopic) satisfy it by construction.
- */
- const NTFY_TOPIC_RE = /^pulse-me-[A-Za-z0-9-]{6,64}$/;
- ```
- The `isValidNtfyTopic` function is unchanged (still `NTFY_TOPIC_RE.test(input)`).
-
-2. Update `mintNtfyTopic()` (around line 56) — keep the 8 hex chars of entropy but change the prefix to `pulse-me-`:
- ```ts
- export function mintNtfyTopic(): string {
- const id = randomUUID().replace(/-/g, '').slice(0, 8);
- return `pulse-me-${id}`;
- }
- ```
-
-3. Update `sendChannelTest` ntfy branch (around line 103). The current code reads `channel.config.server_url || 'https://ntfy.sh'` and reads `channel.config.auth_token` from the channel row. For personal channels (this function is only called from `/api/me/channels/[type]/...` routes, so EVERY channel passed in is personal), force the company server and the company token:
-
- Replace the `case 'ntfy':` block body (preserve the `case 'ntfy': { ... }` shell and surrounding error returns):
- ```ts
- case 'ntfy': {
- // Personal channels (owner_user_id set) are forced to the company ntfy
- // server with the company bearer token (UAT-FIX-01). The channel.config
- // .server_url / .auth_token fields are ignored for personal rows.
- const serverUrl = process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud';
- const token = process.env.NTFY_PULSE_TOKEN;
- const topic = channel.config.topic;
- if (!topic) return { ok: false, error: 'ntfy channel missing topic' };
- if (!token) {
- // Fail loud on misconfiguration — without the token publishes are 401.
- return { ok: false, error: 'NTFY_PULSE_TOKEN not configured' };
- }
- const headers: Record = {
- 'Content-Type': 'text/plain',
- 'Title': 'Pulse channel verified',
- 'Authorization': `Bearer ${token}`,
- };
- const resp = await fetch(`${serverUrl}/${topic}`, {
- method: 'POST',
- headers,
- body: TEST_MESSAGE_BODY,
- });
- if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) };
- return { ok: true };
- }
- ```
- Reason for full replacement: the current branch reads `channel.config.auth_token` which is fine for legacy admin rows but personal rows never store a token (and shouldn't — admins can read another user's webhook URL per D-07, but a per-channel token is just dead config). Drop the channel.config.auth_token read entirely for this path.
-
-**B. `lib/services/pipeline-steps/notify.ts`** — surgical edit to `sendNtfy` (around line 358):
-
-This function is called from BOTH the global-channel dispatch path (`dispatchToGlobalChannel` — owner_user_id IS NULL) AND the personal-route path (`dispatchUserRoute` — owner_user_id IS NOT NULL). The channel row carries the discriminator. Branch on it:
-
-Replace the `sendNtfy` function body (around lines 358-396). Preserve the function signature and the `notified: true, channel: 'ntfy'` success shape. Insert a personal-vs-global branch at the top:
-
-```ts
-async function sendNtfy(
- channel: NotificationChannel,
- config: Record,
- message: string,
-): Promise {
- const topic = channel.config.topic;
- if (!topic) {
- return { success: false, error: 'ntfy channel missing topic' };
- }
-
- // Personal channels (owner_user_id set) are forced to the company ntfy
- // server with the company bearer token (UAT-FIX-01). Global / admin rows
- // (owner_user_id NULL) retain their existing config-driven behavior so
- // legacy ntfy.sh deployments and custom self-hosted instances keep working.
- const isPersonal = !!channel.owner_user_id;
- const serverUrl = isPersonal
- ? (process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud')
- : (channel.config.server_url || 'https://ntfy.sh');
-
- const headers: Record = {
- 'Content-Type': 'text/plain',
- };
-
- if (config.title || channel.config.default_title) {
- headers['Title'] = config.title || channel.config.default_title;
- }
- if (config.priority || channel.config.default_priority) {
- headers['Priority'] = config.priority || channel.config.default_priority;
- }
-
- if (isPersonal) {
- const token = process.env.NTFY_PULSE_TOKEN;
- if (!token) {
- return { success: false, error: 'NTFY_PULSE_TOKEN not configured' };
- }
- headers['Authorization'] = `Bearer ${token}`;
- } else if (channel.config.auth_token) {
- headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
- }
-
- const resp = await fetch(`${serverUrl}/${topic}`, {
- method: 'POST',
- headers,
- body: message,
- });
-
- if (!resp.ok) {
- const errText = await resp.text();
- return { success: false, error: `ntfy failed (${resp.status}): ${errText.substring(0, 200)}` };
- }
-
- return { success: true, output: { notified: true, channel: 'ntfy' } };
-}
-```
-
-**C. `lib/services/pipeline-steps/approval.ts`** — surgical edit to the `else if (channel.channel_type === 'ntfy')` branch (around line 113-131). Apply the same personal-vs-global split:
-
-Replace the `} else if (channel.channel_type === 'ntfy') { ... }` block with:
-```ts
- } else if (channel.channel_type === 'ntfy') {
- // Personal channels forced to company server + token (UAT-FIX-01).
- // Global rows retain their config-driven behavior.
- const isPersonal = !!channel.owner_user_id;
- const serverUrl = isPersonal
- ? (process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud')
- : (channel.config.server_url || 'https://ntfy.sh');
- const headers: Record = {
- 'Title': 'Approval Required',
- 'Priority': 'high',
- 'Tags': 'warning',
- 'Actions': options.map(opt =>
- `http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST`
- ).join('; '),
- };
- if (isPersonal) {
- const token = process.env.NTFY_PULSE_TOKEN;
- if (token) headers['Authorization'] = `Bearer ${token}`;
- // If token missing, send unauthenticated — approval is best-effort and
- // the parent try/catch logs failures. Loud failure would block the
- // whole approval step for one missing env var.
- } else if (channel.config.auth_token) {
- headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
- }
- await fetch(`${serverUrl}/${channel.config.topic}`, {
- method: 'POST',
- headers,
- body: message,
- });
- }
-```
-Rationale for the softer fallback here (vs. `notify.ts`'s loud error): the approval step is wrapped in a try/catch and just logs failures (see line 134); a hard return is not in this code path's vocabulary.
-
-**D. `lib/services/ticket-digest-service.ts`** — surgical edit to the `deliver()` method's ntfy branch (around line 648-655):
-
-Replace the `} else if (ch.channel_type === 'ntfy') { ... }` block with:
-```ts
- } else if (ch.channel_type === 'ntfy') {
- // Personal channels forced to company server + token (UAT-FIX-01).
- // Global rows retain config-driven behavior so admin-configured
- // digest channels keep working.
- const isPersonal = !!(ch as NotificationChannel & { owner_user_id?: string | null }).owner_user_id;
- const server = isPersonal
- ? (process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud')
- : (ch.config.server_url || 'https://ntfy.sh');
- const topic = ch.config.topic;
- if (!topic) throw new Error('ntfy missing topic');
- const headers: Record = { 'Content-Type': 'text/plain', 'Title': `Ticket Digest — ${stats.period.label}` };
- if (isPersonal) {
- const token = process.env.NTFY_PULSE_TOKEN;
- if (token) headers['Authorization'] = `Bearer ${token}`;
- } else if (ch.config.auth_token) {
- headers['Authorization'] = `Bearer ${ch.config.auth_token}`;
- }
- if (ch.config.default_priority) headers['Priority'] = ch.config.default_priority;
- res = await fetch(`${server}/${topic}`, { method: 'POST', headers, body: plainText });
- }
-```
-
-Note on the type cast: the local `NotificationChannel` interface in `ticket-digest-service.ts` (around line 28) does NOT declare `owner_user_id`. Rather than mutate that local interface and risk type churn elsewhere, the cast above reads the column at runtime. The select at line 612 already pulls `id, name, channel_type, config, is_active` — extend that select to include `owner_user_id`:
-
-In `getAvailableChannels` (line 158) AND in `deliver` (line 612), change:
-```ts
-'SELECT id, name, channel_type, config, is_active FROM notification_channels ...'
-```
-to include the column:
-```ts
-'SELECT id, name, channel_type, config, is_active, owner_user_id FROM notification_channels ...'
-```
-This keeps the runtime cast honest. The local interface stays as-is — the field is read via the cast and is allowed to be undefined.
-
-Run type check after all four files are saved.
-
-
- npx tsc --noEmit --pretty
-
-
-- `lib/services/personal-channels.ts` contains `NTFY_TOPIC_RE = /^pulse-me-[A-Za-z0-9-]{6,64}$/` and `mintNtfyTopic()` returns `pulse-me-XXXXXXXX`.
-- `sendChannelTest`'s ntfy branch posts to `${NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'}/${topic}` with `Authorization: Bearer ${NTFY_PULSE_TOKEN}` — no read of `channel.config.server_url` / `channel.config.auth_token` in this branch.
-- `lib/services/pipeline-steps/notify.ts` `sendNtfy` branches on `channel.owner_user_id`: personal → company server + bearer NTFY_PULSE_TOKEN; global → existing config-driven behavior preserved.
-- `lib/services/pipeline-steps/approval.ts` ntfy branch applies the same personal-vs-global split.
-- `lib/services/ticket-digest-service.ts` `deliver()` ntfy branch applies the same split, and both `getAvailableChannels` + `deliver` SELECTs include `owner_user_id`.
-- `npx tsc --noEmit --pretty` exits 0.
-
-
-
-
- Task 2: Update ProfileChannelsSection.tsx (QR + subscribe link + inline error copy) and verify with existing tests
- components/mobile/profile/ProfileChannelsSection.tsx
-
-Three surgical edits to `components/mobile/profile/ProfileChannelsSection.tsx`:
-
-1. Introduce a module-level constant (just after the imports, before the `interface Channel` block at line 32):
- ```ts
- // Personal ntfy channels target the company ntfy instance. The fallback
- // matches the server-side default in personal-channels.ts / notify.ts so
- // the UI and the publish path stay aligned even when the env var is unset
- // in a dev shell (UAT-FIX-01).
- const NTFY_BASE = process.env.NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud';
- ```
-
-2. Update the subscribe link and QR code in the State B branch (around lines 281-296). Replace both `https://ntfy.sh/${ntfyTopic}` occurrences with `${NTFY_BASE}/${ntfyTopic}`:
- ```tsx
-
- {`${NTFY_BASE}/${ntfyTopic}`}
-
-
-
-
-
- ```
-
-3. The inline-error pattern for the custom-topic Input (line 318-320) currently reads the server's `message` body — and the server already returns the regex string. The Phase 9 plan's contract is "render `body.message || body.error` inline" and the server now returns a message anchored to the new regex. We keep that pattern (no change to `setCustomTopicError`), BUT we also add a fallback hint string so the field has a useful placeholder/help line even before the user submits.
-
- Find the ` ` block (around line 310-317). Just AFTER the Input, BEFORE the `{customTopicError && ...}` line, add a tiny help line:
- ```tsx
-
- Topic must start with pulse-me-.
-
- ```
- This gives the user actionable guidance without depending on the server error text (which is already rendered below it on 400).
-
- Do NOT remove the existing `{customTopicError && ({customTopicError}
)}` — that line continues to render the server's `message` on 400. The help line and the error line are stacked.
-
-No other edits to this file. Do NOT touch the Teams sub-section. Do NOT touch any state variables. Do NOT introduce new imports.
-
-Also: per the UAT gap (cleanup item) — there may be stray rows in `notification_channels` with the old `pulse-` topic prefix from QA testing. The cleanup is optional (UAT note says "low-volume QA data only"). Skip the cleanup step in this plan — if the user has stale test rows, they can DELETE manually from the admin Channels page (D-07 grants admins full edit access). If you find a stale row blocking your own smoke test, delete it via the admin UI rather than adding SQL to this plan.
-
-
- npx tsc --noEmit --pretty && npx vitest run lib/services/pipeline-steps/notify.test.ts
-
-
-- `components/mobile/profile/ProfileChannelsSection.tsx` defines `const NTFY_BASE = process.env.NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'` at module scope.
-- The subscribe link `href` and rendered text both use `${NTFY_BASE}/${ntfyTopic}` — `ntfy.sh` no longer appears anywhere in the file (grep confirms: `grep -n "ntfy.sh" components/mobile/profile/ProfileChannelsSection.tsx` returns nothing).
-- A muted help line `Topic must start with pulse-me-` renders between the custom-topic Input and the `customTopicError` paragraph.
-- `npx tsc --noEmit --pretty` exits 0.
-- `npx vitest run lib/services/pipeline-steps/notify.test.ts` passes (the muted-user behavioral test must still pass — the personal/global branch change in sendNtfy MUST NOT regress mute semantics).
-
-
-
-
-
-
-## Trust Boundaries
-
-| Boundary | Description |
-|----------|-------------|
-| Client → Pulse API | User-supplied custom ntfy topic crosses here (validated against `NTFY_TOPIC_RE`) |
-| Pulse server → ntfy.wulfconsulting.cloud | Bearer-authenticated publish; token sourced from server env |
-| Browser → ntfy.wulfconsulting.cloud | Read-only subscribe (no token exposure — subscription happens in the user's ntfy app, not in Pulse) |
-
-## STRIDE Threat Register
-
-| Threat ID | Category | Component | Disposition | Mitigation Plan |
-|-----------|----------|-----------|-------------|-----------------|
-| T-09.1-01 | Spoofing | Personal ntfy topic | mitigate | `pulse-me-` prefix enforcement keeps personal topics out of the `noc-*`/`soc-*` namespace — a user cannot mint or save a topic that would receive NOC/SOC traffic |
-| T-09.1-02 | Tampering | Custom topic Input | mitigate | Server-side regex `^pulse-me-[A-Za-z0-9-]{6,64}$` enforced in `personal-channels.ts` `isValidNtfyTopic`; client-side help line is advisory only — server is the gate |
-| T-09.1-03 | Information Disclosure | NTFY_PULSE_TOKEN | mitigate | Token only read in server-side modules (`personal-channels.ts`, `notify.ts`, `approval.ts`, `ticket-digest-service.ts`); never exposed via NEXT_PUBLIC_ env var; never logged (existing `sendChannelTest` does not log `channel.config`, and the new branch does not log `token`) |
-| T-09.1-04 | Information Disclosure | Bearer header in fetch error path | accept | If `fetch` throws and the error message includes the request, it could surface the Authorization header. The existing `e instanceof Error ? e.message : 'unknown error'` path returns a plain message string from `Error.message`, not the full request. No additional masking added — risk is low and well-scoped |
-| T-09.1-05 | Denial of Service | Missing NTFY_PULSE_TOKEN | mitigate | `notify.ts` and `personal-channels.ts` return a structured error (`'NTFY_PULSE_TOKEN not configured'`) when the token is unset for a personal send — fails loud rather than hitting ntfy unauthenticated and burning quota; `approval.ts` uses a softer fallback to match its existing best-effort posture |
-| T-09.1-06 | Elevation of Privilege | Global ntfy rows | accept | Global rows (`owner_user_id IS NULL`) retain their existing `channel.config.server_url` / `channel.config.auth_token` behavior — out-of-scope per UAT diagnosis. Admin-created channels can still target `ntfy.sh` or self-hosted instances with custom tokens |
-
-
-
-After both tasks land:
-
-1. `npx tsc --noEmit --pretty` — must exit 0 (no type errors introduced).
-2. `npx vitest run lib/services/pipeline-steps/notify.test.ts` — must pass (the muted-user behavioral test confirms the personal/global branch did not regress mute semantics).
-3. Grep sanity checks:
- - `grep -rn "ntfy.sh" lib/services/personal-channels.ts` → empty (no fallback to public server).
- - `grep -rn "pulse-" lib/services/personal-channels.ts` → matches reference `pulse-me-` only.
- - `grep -n "NEXT_PUBLIC_NTFY_BASE_URL" components/mobile/profile/ProfileChannelsSection.tsx` → one match.
- - `grep -n "ntfy.sh" components/mobile/profile/ProfileChannelsSection.tsx` → empty.
- - `grep -n "owner_user_id" lib/services/pipeline-steps/notify.ts lib/services/pipeline-steps/approval.ts lib/services/ticket-digest-service.ts` → at least one match in each (the personal-vs-global branch).
-4. Manual UAT retest (gap-closure spot check, optional in this plan):
- - Open `/mobile/profile`, enable mobile push → minted topic starts with `pulse-me-` → QR code value reads `https://ntfy.wulfconsulting.cloud/pulse-me-XXXXXXXX` → ntfy app subscribe works → "Test now" delivers a notification.
- - Open "Edit advanced", enter `bad-topic` → server returns 400, inline error reads message anchored to new regex.
-
-
-
-- All four backend files send to `${NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'}` for personal ntfy channels (owner_user_id IS NOT NULL) with `Authorization: Bearer ${NTFY_PULSE_TOKEN}`.
-- Global ntfy rows (owner_user_id IS NULL) continue to use `channel.config.server_url || 'https://ntfy.sh'` and `channel.config.auth_token` — out-of-scope behavior preserved.
-- Personal topics minted server-side use the `pulse-me-` prefix; the regex rejects `pulse-`, `noc-`, `soc-`, and arbitrary names.
-- `/mobile/profile` QR + subscribe link target the company server.
-- `npx tsc --noEmit --pretty` and `npx vitest run lib/services/pipeline-steps/notify.test.ts` both pass.
-
-
-
-After completion, create `.planning/phases/09.1-ntfy-backend-fix/09.1-01-SUMMARY.md` per the standard summary template, including a Gap Closure section that maps each `missing` item from `09-HUMAN-UAT.md` to the file/line where it was addressed.
-
diff --git a/.planning/phases/09.1-ntfy-backend-fix/09.1-01-SUMMARY.md b/.planning/phases/09.1-ntfy-backend-fix/09.1-01-SUMMARY.md
deleted file mode 100644
index cb39e30..0000000
--- a/.planning/phases/09.1-ntfy-backend-fix/09.1-01-SUMMARY.md
+++ /dev/null
@@ -1,139 +0,0 @@
----
-phase: 09.1-ntfy-backend-fix
-plan: "01"
-subsystem: notifications
-tags: [ntfy, personal-channels, bearer-auth, mobile-profile, gap-closure]
-
-requires:
- - phase: 09-user-profile-preferences-new
- provides: "personal-channels.ts, notify.ts route_to_user path, ProfileChannelsSection.tsx, notification_channels.owner_user_id"
-
-provides:
- - "pulse-me- topic prefix enforced by regex and mint function"
- - "All four ntfy publish sites route personal channels to company ntfy server with bearer auth"
- - "Global/admin ntfy rows continue using their config-driven server + auth_token"
- - "ProfileChannelsSection QR + subscribe link target NEXT_PUBLIC_NTFY_BASE_URL"
- - "Custom-topic help line in ProfileChannelsSection advanced disclosure"
-
-affects:
- - "Any plan adding new ntfy publish sites (must apply same personal/global branch)"
- - "Any plan touching ProfileChannelsSection advanced section"
-
-tech-stack:
- added: []
- patterns:
- - "Personal-vs-global ntfy branch on owner_user_id: isPersonal = !!channel.owner_user_id; personal -> NTFY_BASE_URL + NTFY_PULSE_TOKEN; global -> channel.config.server_url + channel.config.auth_token"
- - "NEXT_PUBLIC_NTFY_BASE_URL module-level constant in ProfileChannelsSection with fallback to https://ntfy.wulfconsulting.cloud"
-
-key-files:
- modified:
- - lib/services/personal-channels.ts
- - lib/services/pipeline-steps/notify.ts
- - lib/services/pipeline-steps/approval.ts
- - lib/services/ticket-digest-service.ts
- - components/mobile/profile/ProfileChannelsSection.tsx
-
-key-decisions:
- - "pulse-me- prefix (not pulse-) to avoid collision with noc-* and soc-* reserved namespaces on company ntfy instance"
- - "approval.ts uses soft fallback when NTFY_PULSE_TOKEN missing (send unauthenticated) to match its existing best-effort posture; notify.ts and personal-channels.ts fail loud"
- - "ticket-digest-service.ts local NotificationChannel interface NOT extended — owner_user_id read via type cast to avoid type churn in a file that is self-contained"
- - "Global/admin ntfy rows (owner_user_id IS NULL) preserved — existing ntfy.sh and custom self-hosted deployments keep working"
-
-requirements-completed: [CHAN-03, CHAN-05, CHAN-07, ROUTE-04]
-
-duration: 15min
-completed: "2026-05-11"
----
-
-# Phase 09.1 Plan 01: ntfy Backend Fix Summary
-
-**Rerouted all four personal-channel ntfy publish sites from public ntfy.sh to the company private server (https://ntfy.wulfconsulting.cloud) with bearer auth, tightened topic prefix from `pulse-` to `pulse-me-`, and aligned the mobile profile QR/subscribe-link to the company server.**
-
-## Performance
-
-- **Duration:** ~15 min
-- **Started:** 2026-05-11T06:30:00Z
-- **Completed:** 2026-05-11T06:45:00Z
-- **Tasks:** 2
-- **Files modified:** 5
-
-## Accomplishments
-
-- Updated `NTFY_TOPIC_RE` from `/^[A-Za-z0-9_-]{6,64}$/` to `/^pulse-me-[A-Za-z0-9-]{6,64}$/` — rejects `pulse-`, `noc-`, `soc-`, and arbitrary names
-- Updated `mintNtfyTopic()` to return `pulse-me-XXXXXXXX` (8 hex chars entropy, same as before)
-- Rewrote `sendChannelTest` ntfy case: drops `channel.config.server_url` / `channel.config.auth_token`; always uses `NTFY_BASE_URL` + `NTFY_PULSE_TOKEN` Bearer header; fails loud when token missing
-- Rewrote `sendNtfy` in `notify.ts`: personal branch (`owner_user_id` set) → company server + NTFY_PULSE_TOKEN; global branch → existing config-driven behavior preserved
-- Updated `approval.ts` ntfy block: same personal/global split; soft fallback when token missing (matching the function's existing best-effort posture)
-- Updated `ticket-digest-service.ts` deliver() ntfy block: same split; both `getAvailableChannels` and `deliver` SELECTs now include `owner_user_id`
-- Added `NTFY_BASE` constant in `ProfileChannelsSection.tsx` using `NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'`
-- Updated subscribe link href + text + QRCodeSVG value to use `NTFY_BASE` — `ntfy.sh` no longer appears in the file
-- Added muted help line `Topic must start with pulse-me-` between custom-topic Input and the server error paragraph
-
-## Task Commits
-
-| Task | Name | Commit | Files |
-|------|------|--------|-------|
-| 1 | Backend: regex + prefix + bearer auth (4 files) | 2ff2dc9 | lib/services/personal-channels.ts, lib/services/pipeline-steps/notify.ts, lib/services/pipeline-steps/approval.ts, lib/services/ticket-digest-service.ts |
-| 2 | ProfileChannelsSection QR + subscribe link + help line | 985728a | components/mobile/profile/ProfileChannelsSection.tsx |
-
-## Gap Closure
-
-Maps each `missing` item from `09-HUMAN-UAT.md` to where it was addressed:
-
-| Gap Item | File | Location |
-|----------|------|----------|
-| Env vars NTFY_BASE_URL / NEXT_PUBLIC_NTFY_BASE_URL | personal-channels.ts, notify.ts, approval.ts, ticket-digest-service.ts, ProfileChannelsSection.tsx | Read at runtime with fallback default; not written to .env |
-| mintNtfyTopic() must mint `pulse-me-XXXXXXXX` | lib/services/personal-channels.ts | Line 65: `return \`pulse-me-\${id}\`` |
-| NTFY_TOPIC_RE must enforce `^pulse-me-[A-Za-z0-9-]{6,64}$` | lib/services/personal-channels.ts | Lines 44-52: updated regex constant |
-| sendChannelTest must force NTFY_BASE_URL + NTFY_PULSE_TOKEN | lib/services/personal-channels.ts | Lines 103-130: ntfy case rewritten |
-| notify.ts ntfy publish: company server + bearer auth for personal | lib/services/pipeline-steps/notify.ts | Lines 365-395: personal/global branch in sendNtfy |
-| ticket-digest-service.ts ntfy publish: same pattern | lib/services/ticket-digest-service.ts | Lines 651-664: isPersonal branch + owner_user_id in SELECTs |
-| ProfileChannelsSection QR + subscribe link target NEXT_PUBLIC_NTFY_BASE_URL | components/mobile/profile/ProfileChannelsSection.tsx | Lines 35, 295, 302: NTFY_BASE constant + usage |
-| Inline error copy updated to 'Topic must start with pulse-me-' | components/mobile/profile/ProfileChannelsSection.tsx | Lines 321-323: muted help line added |
-
-## Deviations from Plan
-
-### Auto-fixed Issues
-
-**[Rule 3 - Blocking] Restored lib/types/pipeline.ts and other files after git reset --soft**
-
-- **Found during:** Pre-task setup — `git reset --soft` to the expected base commit left the working tree files at an older state, causing Phase 9 types (RouteToUser, owner_user_id, etc.) to be missing from pipeline.ts
-- **Fix:** `git checkout HEAD -- lib/types/pipeline.ts lib/auth.ts lib/bootstrap.ts lib/services/msgraph-client.ts package.json package-lock.json` to restore those files to the correct HEAD state (1ab3bfe)
-- **Files modified:** lib/types/pipeline.ts (restored), lib/auth.ts (restored), lib/bootstrap.ts (restored), others
-- **Impact:** No functional change to the 5 plan target files; purely a worktree hygiene issue
-
-## Known Stubs
-
-None — all changes wire directly to environment variables already present in .env (NTFY_PULSE_TOKEN) or read at runtime with sensible defaults.
-
-## Threat Surface
-
-No new trust boundaries beyond those declared in the plan's threat model (T-09.1-01 through T-09.1-06). All mitigations applied:
-
-- T-09.1-01: `pulse-me-` prefix keeps personal topics out of noc-*/soc-* namespace
-- T-09.1-02: Server-side regex gate enforced in isValidNtfyTopic; client help line is advisory
-- T-09.1-03: NTFY_PULSE_TOKEN only read in server-side modules; never exposed via NEXT_PUBLIC_
-- T-09.1-05: notify.ts + personal-channels.ts return structured error when token missing; approval.ts uses soft fallback
-
-## Self-Check: PASSED
-
-Files exist:
-- lib/services/personal-channels.ts: FOUND
-- lib/services/pipeline-steps/notify.ts: FOUND
-- lib/services/pipeline-steps/approval.ts: FOUND
-- lib/services/ticket-digest-service.ts: FOUND
-- components/mobile/profile/ProfileChannelsSection.tsx: FOUND
-
-Commits exist:
-- 2ff2dc9: FOUND (Task 1 — backend 4 files)
-- 985728a: FOUND (Task 2 — ProfileChannelsSection)
-
-TypeScript: `npx tsc --noEmit --pretty` exit 0 — no errors.
-Vitest: `npx vitest run lib/services/pipeline-steps/notify.test.ts` — 1 passed.
-
-Grep sanity:
-- grep "ntfy.sh" lib/services/personal-channels.ts → (none)
-- grep "pulse-me-" lib/services/personal-channels.ts → 3 matches (comment, regex, return)
-- grep "NEXT_PUBLIC_NTFY_BASE_URL" components/mobile/profile/ProfileChannelsSection.tsx → 1 match (line 35)
-- grep "ntfy.sh" components/mobile/profile/ProfileChannelsSection.tsx → (none)
-- grep "owner_user_id" notify.ts approval.ts ticket-digest-service.ts → multiple matches in each
diff --git a/.planning/phases/09.1-ntfy-backend-fix/09.1-HUMAN-UAT.md b/.planning/phases/09.1-ntfy-backend-fix/09.1-HUMAN-UAT.md
deleted file mode 100644
index d99ea85..0000000
--- a/.planning/phases/09.1-ntfy-backend-fix/09.1-HUMAN-UAT.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-status: partial
-phase: 09.1-ntfy-backend-fix
-source: [09.1-VERIFICATION.md]
-started: 2026-05-11T06:48:00Z
-updated: 2026-05-11T06:48:00Z
----
-
-## Current Test
-
-[awaiting human testing]
-
-## Tests
-
-### 1. End-to-end personal ntfy delivery
-expected: On /mobile/profile open Channels section. Enable ntfy. Verify the minted topic shown begins with `pulse-me-`. Scan/open the QR code or subscribe link — it points at `https://ntfy.wulfconsulting.cloud/pulse-me-...`. Tap "Test now" and confirm a notification arrives on your ntfy app within a few seconds.
-result: [pending]
-
-### 2. ntfy custom-topic inline error rejects bad input
-expected: Open the Channels section, expand 'Edit advanced' on ntfy, enter `bad-topic` (or any value not starting with `pulse-me-`). Save. Inline `text-xs text-destructive` error shows below the input reading "Topic must start with pulse-me-..." (or similar). No success toast fires.
-result: [pending]
-
-## Summary
-
-total: 2
-passed: 0
-issues: 0
-pending: 2
-skipped: 0
-blocked: 0
-
-## Gaps
diff --git a/.planning/phases/09.1-ntfy-backend-fix/09.1-VERIFICATION.md b/.planning/phases/09.1-ntfy-backend-fix/09.1-VERIFICATION.md
deleted file mode 100644
index b33e538..0000000
--- a/.planning/phases/09.1-ntfy-backend-fix/09.1-VERIFICATION.md
+++ /dev/null
@@ -1,117 +0,0 @@
----
-phase: 09.1-ntfy-backend-fix
-verified: 2026-05-11T07:00:00Z
-status: human_needed
-score: 6/6 must-haves verified
-re_verification: false
-human_verification:
- - test: "Personal ntfy channel end-to-end delivery on mobile"
- expected: "Open /mobile/profile, enable mobile push, confirm minted topic starts with pulse-me-, confirm QR code value reads https://ntfy.wulfconsulting.cloud/pulse-me-XXXXXXXX, scan QR with ntfy app, tap 'Test now' — notification arrives on device with the company server bearer auth"
- why_human: "Cannot verify actual ntfy delivery to a physical device programmatically; requires NTFY_PULSE_TOKEN to be set and the company ntfy server to be reachable"
- - test: "Custom-topic inline error on 400"
- expected: "Open 'Edit advanced', enter 'bad-topic' (no pulse-me- prefix), tap 'Save custom topic'. Server returns 400. The destructive error paragraph renders the server's message string below the help line."
- why_human: "Requires a running server with a live session to trigger the 400 path and observe inline rendering"
----
-
-# Phase 09.1: ntfy Backend Fix — Verification Report
-
-**Phase Goal:** Close the major gap surfaced in Phase 09 UAT — personal ntfy channels must use the company server (https://ntfy.wulfconsulting.cloud) with bearer auth via NTFY_PULSE_TOKEN and the `pulse-me-` topic prefix, while global/admin ntfy channels continue to honor their per-channel server_url + auth_token config.
-**Verified:** 2026-05-11T07:00:00Z
-**Status:** human_needed
-**Re-verification:** No — initial verification
-
-## Goal Achievement
-
-All 6 must-have truths are VERIFIED in code. Two human spot-checks remain for end-to-end delivery confirmation and inline-error UI rendering.
-
-### Observable Truths
-
-| # | Truth | Status | Evidence |
-|---|-------|--------|----------|
-| 1 | Personal ntfy channels mint topics with the `pulse-me-` prefix (8 hex chars of entropy) | VERIFIED | `personal-channels.ts` line 52: `NTFY_TOPIC_RE = /^pulse-me-[A-Za-z0-9-]{6,64}$/`; line 65: `return \`pulse-me-${id}\`` |
-| 2 | Custom ntfy topics accepted only when they match `^pulse-me-[A-Za-z0-9-]{6,64}$` — `pulse-`, `noc-`, `soc-`, and arbitrary names rejected | VERIFIED | `isValidNtfyTopic` uses `NTFY_TOPIC_RE` unchanged; regex updated in place at line 52 |
-| 3 | All four ntfy send paths (sendChannelTest, notify.ts sendNtfy, approval.ts ntfy branch, ticket-digest-service.ts ntfy branch) target the company server + Bearer NTFY_PULSE_TOKEN for personal channels | VERIFIED | All four files branch on `owner_user_id`/`isPersonal` and read `process.env.NTFY_BASE_URL \|\| 'https://ntfy.wulfconsulting.cloud'` and `process.env.NTFY_PULSE_TOKEN` (see Artifacts table) |
-| 4 | QR code and subscribe link in /mobile/profile point at the company ntfy host (NEXT_PUBLIC_NTFY_BASE_URL) | VERIFIED | `ProfileChannelsSection.tsx` line 35: `const NTFY_BASE = process.env.NEXT_PUBLIC_NTFY_BASE_URL \|\| 'https://ntfy.wulfconsulting.cloud'`; used at lines 288, 293, 301; `ntfy.sh` not present in file |
-| 5 | Inline error copy under the custom-topic Input reads 'Topic must start with pulse-me-' on 400 | VERIFIED (static help line) | `ProfileChannelsSection.tsx` line 324-326: `Topic must start with pulse-me-.
` renders between Input and server-error paragraph; server-error paragraph unchanged |
-| 6 | Global/admin ntfy rows (owner_user_id IS NULL) continue to use config.server_url + config.auth_token — only personal channels forced to company server + NTFY_PULSE_TOKEN | VERIFIED | All four send sites: `global` branch reads `channel.config.server_url \|\| 'https://ntfy.sh'` and `channel.config.auth_token`; `isPersonal = !!channel.owner_user_id` gates the two paths cleanly |
-
-**Score:** 6/6 truths verified
-
-### Required Artifacts
-
-| Artifact | Expected | Status | Details |
-|----------|----------|--------|---------|
-| `lib/services/personal-channels.ts` | Updated NTFY_TOPIC_RE, mintNtfyTopic, sendChannelTest forcing company server + bearer auth | VERIFIED | Line 52: `NTFY_TOPIC_RE = /^pulse-me-[A-Za-z0-9-]{6,64}$/`; line 65: `return \`pulse-me-${id}\``; lines 110-133: `sendChannelTest` ntfy case uses `NTFY_BASE_URL` + `NTFY_PULSE_TOKEN`, drops `channel.config.auth_token` path |
-| `lib/services/pipeline-steps/notify.ts` | sendNtfy branches on owner_user_id; personal → company server + NTFY_PULSE_TOKEN | VERIFIED | Lines 368-396: `isPersonal = !!channel.owner_user_id`; personal branch reads `process.env.NTFY_BASE_URL` and `process.env.NTFY_PULSE_TOKEN`; global branch reads `channel.config.server_url` and `channel.config.auth_token` |
-| `lib/services/pipeline-steps/approval.ts` | ntfy approval branch applies personal/global split | VERIFIED | Lines 113-141: `isPersonal = !!channel.owner_user_id`; personal → `NTFY_BASE_URL` + `NTFY_PULSE_TOKEN` (soft fallback when token missing, matching existing best-effort posture) |
-| `lib/services/ticket-digest-service.ts` | deliver() ntfy branch applies personal/global split; both SELECTs include owner_user_id | VERIFIED | Line 159 (getAvailableChannels SELECT) and line 612 (deliver SELECT) both include `owner_user_id`; lines 648-665: ntfy branch reads `isPersonal` via type cast; personal → `NTFY_BASE_URL` + `NTFY_PULSE_TOKEN` |
-| `components/mobile/profile/ProfileChannelsSection.tsx` | NTFY_BASE constant + QR + subscribe link target NEXT_PUBLIC_NTFY_BASE_URL; inline error copy updated | VERIFIED | Line 35: `NTFY_BASE` constant; lines 288, 293, 301: `NTFY_BASE` used for href, rendered text, QRCodeSVG value; line 324-326: help line added; `ntfy.sh` not present anywhere in file |
-
-### Key Link Verification
-
-| From | To | Via | Status | Details |
-|------|----|-----|--------|---------|
-| `personal-channels.ts mintNtfyTopic` | minted topic value returned to PUT /api/me/channels/ntfy | returns `pulse-me-${id}` | WIRED | Line 65: `return \`pulse-me-${id}\``; regex on line 52 validates the same prefix |
-| `personal-channels.ts sendChannelTest (ntfy)` | `process.env.NTFY_BASE_URL` + `process.env.NTFY_PULSE_TOKEN` | `Authorization: Bearer ${token}` on POST | WIRED | Lines 114-126: `serverUrl` from `NTFY_BASE_URL`, `token` from `NTFY_PULSE_TOKEN`, header set at line 125 |
-| `ProfileChannelsSection.tsx` | `process.env.NEXT_PUBLIC_NTFY_BASE_URL` | `QRCodeSVG value` prop + subscribe `href` | WIRED | Line 35: `NTFY_BASE` reads env var; lines 288 and 301 use `NTFY_BASE` in href and QRCodeSVG value |
-| `notify.ts sendNtfy` | company ntfy server for personal channels | `isPersonal` branch on `channel.owner_user_id` | WIRED | Line 372: `isPersonal = !!channel.owner_user_id`; line 374: personal path reads `NTFY_BASE_URL`; line 389-393: token from `NTFY_PULSE_TOKEN` |
-| `approval.ts ntfy block` | company ntfy server for personal channels | `isPersonal` branch | WIRED | Lines 116-130: identical personal/global split pattern |
-| `ticket-digest-service.ts deliver() ntfy block` | company ntfy server for personal channels | `isPersonal` branch + owner_user_id in SELECT | WIRED | Line 652: `isPersonal` via type cast; line 612 SELECT includes `owner_user_id` |
-
-### Data-Flow Trace (Level 4)
-
-Not applicable — these are notification dispatch utilities, not data-rendering components. The "data" (env vars, channel config) is scalar and verified at the code level via grep.
-
-### Behavioral Spot-Checks
-
-| Behavior | Command | Result | Status |
-|----------|---------|--------|--------|
-| TypeScript type check (no new type errors) | `npx tsc --noEmit` | 0 lines output (exit 0) | PASS |
-| notify.ts vitest (mute semantics not regressed) | `npx vitest run lib/services/pipeline-steps/notify.test.ts` | 1 test file, 1 test passed | PASS |
-| `ntfy.sh` absent from ProfileChannelsSection | `grep -n "ntfy.sh" ProfileChannelsSection.tsx` | (no output) | PASS |
-| `ntfy.sh` absent from personal-channels.ts | `grep -n "ntfy.sh" personal-channels.ts` | (no output) | PASS |
-| owner_user_id in all three pipeline files | `grep -n "owner_user_id" notify.ts approval.ts ticket-digest-service.ts` | Multiple matches in each file | PASS |
-| NEXT_PUBLIC_NTFY_BASE_URL in ProfileChannelsSection | `grep -n "NEXT_PUBLIC_NTFY_BASE_URL" ProfileChannelsSection.tsx` | Line 35: NTFY_BASE constant | PASS |
-| pulse-me- help text rendered in advanced section | `grep -n "pulse-me-" ProfileChannelsSection.tsx` | Line 325: help paragraph found | PASS |
-| ntfy.sh only appears in global channel fallback paths | `grep -n "ntfy.sh" notify.ts approval.ts ticket-digest-service.ts` | Present only as `channel.config.server_url \|\| 'https://ntfy.sh'` in the `!isPersonal` branch | PASS |
-| Both ticket-digest SELECTs include owner_user_id | lines 159, 612 in ticket-digest-service.ts | Both SELECT strings include `owner_user_id` | PASS |
-| Task commits exist | `git log --oneline` | 2ff2dc9 (backend 4 files), 985728a (ProfileChannelsSection) | PASS |
-
-### Requirements Coverage
-
-| Requirement | Source Plan | Description | Status | Evidence |
-|-------------|------------|-------------|--------|----------|
-| CHAN-03 | 09.1-01-PLAN.md | ntfy topic is Pulse-minted on first save with UUID-prefixed topic | SATISFIED | `mintNtfyTopic()` now returns `pulse-me-XXXXXXXX`; regex enforces that format |
-| CHAN-05 | 09.1-01-PLAN.md | On save, API issues best-effort test send; test result surfaces inline | SATISFIED | `sendChannelTest` (ntfy case) rewired to company server + bearer auth; test result shape unchanged |
-| CHAN-07 | 09.1-01-PLAN.md | /api/me/channels routes auth-gated to session.user.id | SATISFIED | Routes unchanged (Phase 09 wired this); bearer auth for ntfy test sends now uses NTFY_PULSE_TOKEN not channel.config |
-| ROUTE-04 | 09.1-01-PLAN.md | When user route fails, fallback to global channel_id with user_route_fallback recorded | SATISFIED | Fallback logic in notify.ts unchanged; personal-channel ntfy sends now succeed where they previously 401'd (wrong server), reducing spurious fallbacks |
-
-### Anti-Patterns Found
-
-| File | Line | Pattern | Severity | Impact |
-|------|------|---------|----------|--------|
-| `lib/services/ticket-digest-service.ts` | 652 | Type cast `(ch as NotificationChannel & { owner_user_id?: string | null })` | INFO | Deliberate workaround documented in plan: local `NotificationChannel` interface not extended to avoid type churn; cast is safe because SELECT now includes the column. Not a blocker. |
-| `lib/services/pipeline-steps/approval.ts` | 128-131 | Soft fallback: sends unauthenticated when `NTFY_PULSE_TOKEN` missing | INFO | Intentional design decision documented in plan and threat model (T-09.1-05). Matches approval.ts existing best-effort posture. notify.ts uses a loud error instead — appropriate distinction. |
-
-### Human Verification Required
-
-#### 1. Personal ntfy channel end-to-end delivery
-
-**Test:** Sign into Pulse on mobile. Open `/mobile/profile`. Enable mobile push. Confirm the minted topic starts with `pulse-me-`. Confirm the subscribe link text and QR code URL both read `https://ntfy.wulfconsulting.cloud/pulse-me-XXXXXXXX`. Scan QR with the ntfy app to subscribe. Tap "Test now". Confirm a notification arrives.
-**Expected:** The notification is delivered via `https://ntfy.wulfconsulting.cloud/{topic}` with bearer auth. The ntfy app (subscribed to that topic) receives the message "Pulse channel verified — you can ignore this message."
-**Why human:** Requires `NTFY_PULSE_TOKEN` to be set in the runtime env, the company ntfy server to be reachable, and a physical device running the ntfy app. Cannot simulate network delivery programmatically.
-
-#### 2. Custom-topic inline error on 400
-
-**Test:** With an ntfy channel minted, open "Edit advanced" in the ntfy section of `/mobile/profile`. Enter `bad-topic` (no `pulse-me-` prefix). Tap "Save custom topic".
-**Expected:** Server returns HTTP 400. The muted help line `Topic must start with pulse-me-` is visible above a new `text-destructive` paragraph containing the server's error message (e.g. "Topic must start with pulse-me-"). No success toast appears.
-**Why human:** Requires a running Next.js dev or prod server with an authenticated session to trigger the PUT 400 path and observe the inline error rendering behavior.
-
-### Gaps Summary
-
-No gaps. All six must-have truths are satisfied by code that exists, is substantive, and is wired to the correct data sources. The two human verification items are confirmational (delivery and UI rendering) and do not represent structural gaps — all code paths are verified correct. The phase goal is achieved at the implementation level.
-
----
-
-_Verified: 2026-05-11T07:00:00Z_
-_Verifier: Claude (gsd-verifier)_