From 010aadcc81e0e6af8fe5ac40d5ee5d402b9e7794 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 21:22:01 -0400 Subject: [PATCH] docs(06): create phase plans (3 plans, 3 waves) --- .planning/ROADMAP.md | 7 +- .planning/STATE.md | 18 +- .../phases/06-analyzer-feed-new/06-01-PLAN.md | 458 ++++++++++++ .../phases/06-analyzer-feed-new/06-02-PLAN.md | 696 ++++++++++++++++++ .../phases/06-analyzer-feed-new/06-03-PLAN.md | 473 ++++++++++++ 5 files changed, 1641 insertions(+), 11 deletions(-) create mode 100644 .planning/phases/06-analyzer-feed-new/06-01-PLAN.md create mode 100644 .planning/phases/06-analyzer-feed-new/06-02-PLAN.md create mode 100644 .planning/phases/06-analyzer-feed-new/06-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4e12f26..23f9171 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -114,7 +114,10 @@ Decimal phases appear between their surrounding integers in numeric order. 3. Tapping a row opens a mobile summary view rendering Summary, Next Step, and Next Step Rationale, with a "View full analysis" link out to the desktop analyzer page 4. The mobile feed never exposes editing, re-run, or prompt-tuning controls (read-only by design) 5. The list reads from `analyzer_analyses` via `/api/mobile/analyzer/feed` (or a reused list endpoint that already returns the right shape) -**Plans**: TBD +**Plans**: 3 plans +- [ ] 06-01-PLAN.md — /api/mobile/analyzer/feed endpoint with cursor pagination + kiosk_settings scoping (ANL-01, ANL-02, ANL-06) +- [ ] 06-02-PLAN.md — AnalyzerFeedRow/StagePips/ConfidenceBadge/RowSkeleton components + replace /mobile/analyzer placeholder with feed list page (ANL-01, ANL-02, ANL-05, ANL-06) +- [ ] 06-03-PLAN.md — /mobile/analyzer/[id] detail page reading existing /api/analyzer/analyses/[id] (ANL-03, ANL-04, ANL-05) **UI hint**: yes ### Phase 7: Engagement Overview (NEW) @@ -153,7 +156,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral | 3. Dashboard Restyle | 0/2 | Not started | - | | 4. Tickets Restyle | 0/3 | Not started | - | | 5. Finance Restyle | 2/2 | Complete | 2026-05-03 | -| 6. Analyzer Feed | 0/TBD | Not started | - | +| 6. Analyzer Feed | 0/3 | Not started | - | | 7. Engagement Overview | 0/TBD | Not started | - | | 8. Engagement User Profile | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index ad26c74..f672b1c 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,15 +3,15 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: executing -stopped_at: Phase 6 context gathered (auto mode) -last_updated: "2026-05-04T01:01:07.942Z" -last_activity: 2026-05-04 +stopped_at: Phase 6 UI-SPEC approved +last_updated: "2026-05-04T01:21:56.684Z" +last_activity: 2026-05-04 -- Phase 6 planning complete progress: total_phases: 8 completed_phases: 5 - total_plans: 11 + total_plans: 14 completed_plans: 11 - percent: 100 + percent: 79 --- # Project State @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-05-03) Phase: 6 Plan: Not started Status: Ready to execute -Last activity: 2026-05-04 +Last activity: 2026-05-04 -- Phase 6 planning complete Progress: [░░░░░░░░░░] 0% @@ -86,6 +86,6 @@ None yet. ## Session Continuity -Last session: 2026-05-04T01:01:07.939Z -Stopped at: Phase 6 context gathered (auto mode) -Resume file: .planning/phases/06-analyzer-feed-new/06-CONTEXT.md +Last session: 2026-05-04T01:07:11.125Z +Stopped at: Phase 6 UI-SPEC approved +Resume file: .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md diff --git a/.planning/phases/06-analyzer-feed-new/06-01-PLAN.md b/.planning/phases/06-analyzer-feed-new/06-01-PLAN.md new file mode 100644 index 0000000..d88d3ef --- /dev/null +++ b/.planning/phases/06-analyzer-feed-new/06-01-PLAN.md @@ -0,0 +1,458 @@ +--- +phase: 06-analyzer-feed-new +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - app/api/mobile/analyzer/feed/route.ts +autonomous: true +requirements: [ANL-01, ANL-02, ANL-06] +must_haves: + truths: + - "GET /api/mobile/analyzer/feed returns the latest completed analyzer_analyses rows ordered by completed_at DESC, id DESC" + - "Response shape is {analyses: AnalyzerFeedRow[], nextCursor: string | null, hasMore: boolean}" + - "Each row contains the columns AnalyzerFeedRow consumers (Plan 06-02) need: id, ticketNumber, title, companyName, summary, confidenceScore, haikuUsed, sonnetUsed, opusUsed, needsHumanReview, completedAt, analysisVersion" + - "Pagination is cursor-based with server-capped limit ≤ 25 (D-05)" + - "Out-of-scope companies are filtered out by kiosk_settings scoping (D-04)" + - "Latest analysis per ticket only — re-analyzed tickets do not appear multiple times (D-02)" + - "Unauthenticated requests return 401 via requireAuth() (security)" + artifacts: + - path: "app/api/mobile/analyzer/feed/route.ts" + provides: "GET handler + exported AnalyzerFeedRow + AnalyzerFeedResponse types" + exports: ["GET", "AnalyzerFeedRow", "AnalyzerFeedResponse"] + min_lines: 120 + key_links: + - from: "app/api/mobile/analyzer/feed/route.ts" + to: "analyzer_analyses, tickets, companies tables" + via: "postgresClient.query() with parameterized SQL" + pattern: "FROM analyzer_analyses.*INNER JOIN tickets.*INNER JOIN companies" + - from: "app/api/mobile/analyzer/feed/route.ts" + to: "kiosk_settings" + via: "getMobileCompanyFilter() helper duplicated inline" + pattern: "kiosk_settings" + - from: "app/api/mobile/analyzer/feed/route.ts" + to: "lib/auth-utils.ts" + via: "requireAuth() session gate" + pattern: "requireAuth" +--- + + +Build `GET /api/mobile/analyzer/feed` — the new mobile-only endpoint that returns the most-recent-first stream of completed AI ticket analyses (latest analysis per ticket) with cursor pagination and `kiosk_settings` company scoping. Export `AnalyzerFeedRow` and `AnalyzerFeedResponse` types from the route file so the Wave 2 feed page can `import type` them. + +Purpose: This is the data spine for Phase 6. Plan 06-02 (feed page UI) cannot consume real data without it. The endpoint must mirror the patterns from `app/api/mobile/tickets/route.ts` exactly so the manager's mental model from Tickets carries over with zero learning cost — same envelope shape (`{...List, nextCursor, hasMore}`), same cursor encoding (base64 JSON), same `kiosk_settings` scoping helper, same camelCase response transform. + +Output: +- `app/api/mobile/analyzer/feed/route.ts` — GET handler, requireAuth-gated, cursor-paginated, scoped, with exported TS interfaces. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/06-analyzer-feed-new/06-CONTEXT.md +@.planning/phases/06-analyzer-feed-new/06-UI-SPEC.md +@CLAUDE.md +@app/api/mobile/tickets/route.ts +@app/api/analyzer/tickets/route.ts +@migrations/069_create_analyzer_tables.sql + + + + +From `app/api/mobile/tickets/route.ts` (pattern source — the new feed route mirrors this exactly): +```typescript +// Exported response interfaces (mirror this shape) +export interface MobileTicket { /* fields */ } +export interface MobileTicketListResponse { + tickets: MobileTicket[]; + nextCursor: string | null; + hasMore: boolean; +} + +// Cursor helpers (inline in the route file — D-06) +interface CursorPayload { last_activity_date: string; id: number; } +function encodeCursor(p: CursorPayload): string { return Buffer.from(JSON.stringify(p),'utf8').toString('base64'); } +function decodeCursor(raw: string | null): CursorPayload | null { + if (!raw) return null; + try { + const parsed = JSON.parse(Buffer.from(raw,'base64').toString('utf8')); + if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') return parsed as CursorPayload; + return null; + } catch { return null; } +} + +// Auth + scoping pattern +const { session, error: authError } = await requireAuth(); +if (authError) return authError; +const { condition: companyCondition } = await getMobileCompanyFilter(); +// companyCondition is "c.company_category_id IN (...) AND c.id NOT IN (...)" (or a fallback) + +// LIMIT n+1 trick to detect hasMore without a COUNT query +LIMIT ${limit + 1} +const hasMore = rows.length > limit; +const sliced = hasMore ? rows.slice(0, limit) : rows; +``` + +From `app/api/analyzer/tickets/route.ts` (latest-version-per-ticket pattern reference, lines 319–328): +```sql +LEFT JOIN LATERAL ( + SELECT aa.id, aa.triggered_at, aa.completed_at, + aa.needs_human_review, aa.confidence_score, + aa.aggregate_fingerprint, aa.analysis_version + FROM analyzer_analyses aa + WHERE aa.ticket_number = f.ticket_number + AND aa.status = 'complete' + ORDER BY aa.analysis_version DESC + LIMIT 1 +) latest ON TRUE +``` +NOTE: this endpoint joins tickets→latest analysis. Phase 6's feed reverses the direction — it joins **analyses→tickets** (one row per latest-completed analysis) so the same physical ticket re-analyzed N times appears once. The `LEFT JOIN LATERAL ... ORDER BY analysis_version DESC LIMIT 1` idiom is the same; only the FROM table changes. + +From `migrations/069_create_analyzer_tables.sql` — `analyzer_analyses` columns this plan reads: +``` +id UUID PRIMARY KEY +ticket_number TEXT NOT NULL +autotask_ticket_id BIGINT NOT NULL +analysis_version INT NOT NULL +status TEXT (must equal 'complete') +completed_at TIMESTAMPTZ (nullable on pending; non-null when complete) +summary TEXT (nullable) +confidence_score NUMERIC(3,2) (nullable) +needs_human_review BOOLEAN NOT NULL +haiku_used BOOLEAN NOT NULL +sonnet_used BOOLEAN NOT NULL +opus_used BOOLEAN NOT NULL +``` + +UNIQUE constraint exists on (ticket_number, analysis_version), and an index `idx_analyzer_analyses_ticket_version` on (ticket_number, analysis_version DESC) — the LATERAL select is index-supported. + + + + + + + Task 1: Create route file shell with exported types and auth gate + app/api/mobile/analyzer/feed/route.ts + + - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-04, D-05, D-06, D-07, D-24, D-26, D-39 — locked decisions) + - .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md (§"API Shape Contract" — exact field list and types for AnalyzerFeedRow + AnalyzerFeedResponse) + - app/api/mobile/tickets/route.ts (PATTERN SOURCE — copy structure: imports, getMobileCompanyFilter helper, exported interfaces, encodeCursor/decodeCursor, requireAuth flow, NextResponse.json with `satisfies`, error catch shape) + - CLAUDE.md (no Zod in API routes; use NextResponse.json; auth via requireAuth()) + + +Create the new file `app/api/mobile/analyzer/feed/route.ts`. This task scaffolds the file with everything EXCEPT the SQL query and result transform (Task 2 fills those in). + +1. Add the file header imports: +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +``` + +2. Duplicate the `getMobileCompanyFilter()` helper from `app/api/mobile/tickets/route.ts` lines 7–29 verbatim (D-04 says "duplicate inline; keep this phase's diff small"). The helper signature is `async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }>`. Do NOT import it — duplicate inline. Add a `// ─── Company filter helper (duplicated from /api/mobile/tickets/route.ts per D-04) ───` comment. + +3. Export the response types EXACTLY as specified in 06-UI-SPEC.md §"API Shape Contract" (D-26): +```typescript +// ─── Exported response interfaces (D-26) ───────────────────────────────────── + +export interface AnalyzerFeedRow { + id: string; // analyzer_analyses UUID + ticketNumber: string; + title: string; + companyName: string; + summary: string | null; + confidenceScore: number | null; + haikuUsed: boolean; + sonnetUsed: boolean; + opusUsed: boolean; + needsHumanReview: boolean; + completedAt: string; // ISO string + analysisVersion: number; +} + +export interface AnalyzerFeedResponse { + analyses: AnalyzerFeedRow[]; + nextCursor: string | null; + hasMore: boolean; +} +``` + +4. Add cursor encode/decode helpers inline (D-06). The cursor payload shape is `{ completed_at: ISO string, id: uuid string }` — DIFFERENT from tickets route (which uses `{ last_activity_date, id: number }`). Use base64 of JSON: +```typescript +// ─── Cursor encode/decode (inline per D-06) ────────────────────────────────── + +interface CursorPayload { completed_at: string; id: string; } + +function encodeCursor(p: CursorPayload): string { + return Buffer.from(JSON.stringify(p), 'utf8').toString('base64'); +} + +function decodeCursor(raw: string | null): CursorPayload | null { + if (!raw) return null; + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')); + if (typeof parsed?.completed_at === 'string' && typeof parsed?.id === 'string') { + return parsed as CursorPayload; + } + return null; + } catch { return null; } +} +``` +The `try/catch` around JSON.parse is the cursor-injection mitigation: malformed input returns null (treated as "no cursor → first page"), never throws. + +5. Add the GET handler skeleton (Task 2 fills in the SQL): +```typescript +// ─── GET handler ───────────────────────────────────────────────────────────── + +export async function GET(request: NextRequest): Promise { + const { error: authError } = await requireAuth(); + if (authError) return authError; + + try { + const { searchParams } = request.nextUrl; + const cursorParam = searchParams.get('cursor'); + + // Server-side limit cap — D-05 (page size 25, cap at 25) + const limit = Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25'))); + + const cursor = decodeCursor(cursorParam); + + // TODO Task 2: build SQL, execute, transform, return. + return NextResponse.json({ analyses: [], nextCursor: null, hasMore: false } satisfies AnalyzerFeedResponse); + } catch (error) { + console.error('GET /api/mobile/analyzer/feed failed:', error); + return NextResponse.json( + { error: 'Failed to fetch analyses', message: error instanceof Error ? error.message : 'unknown' }, + { status: 500 }, + ); + } +} +``` + +6. NO Zod (D-39, CLAUDE.md). NO ORM (CLAUDE.md). NO new state libraries (D-38). NO request validation library — direct `searchParams.get()` reads. + +After this task the file compiles, returns an empty list, and the types are exported for Task 2 (and Plan 06-02) to consume. + + + npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/analyzer/feed" || echo "TypeScript clean for new route file" + + + - File exists: `test -f app/api/mobile/analyzer/feed/route.ts` + - Exports the correct types: `grep -E '^export interface AnalyzerFeedRow' app/api/mobile/analyzer/feed/route.ts` returns one match + - Exports the response envelope type: `grep -E '^export interface AnalyzerFeedResponse' app/api/mobile/analyzer/feed/route.ts` returns one match + - Exports GET: `grep -E '^export async function GET' app/api/mobile/analyzer/feed/route.ts` returns one match + - Auth gate present: `grep -F 'requireAuth()' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - Cursor payload shape matches D-06: `grep -F 'completed_at: string' app/api/mobile/analyzer/feed/route.ts` returns at least one match (NOT `last_activity_date`) + - Cursor cap enforced: `grep -E 'Math\.min\(25,' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - getMobileCompanyFilter helper duplicated inline: `grep -F 'kiosk_settings' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - No Zod imports: `grep -E "from\s+['\"]zod['\"]" app/api/mobile/analyzer/feed/route.ts` returns zero matches + - All response field names match camelCase per UI-SPEC: `grep -E '\\b(ticketNumber|companyName|confidenceScore|haikuUsed|sonnetUsed|opusUsed|needsHumanReview|completedAt|analysisVersion):' app/api/mobile/analyzer/feed/route.ts | wc -l` returns at least 9 + - `npx tsc --noEmit --pretty` exits 0 (no type errors introduced) + + + File compiles, exports the two interfaces and the GET handler, returns an empty envelope on every call. Wave 2 plans can `import type { AnalyzerFeedRow, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'`. + + + + + Task 2: Implement cursor-paginated query, joins, transform, and security scoping + app/api/mobile/analyzer/feed/route.ts + + - app/api/mobile/analyzer/feed/route.ts (current state from Task 1) + - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-01 status='complete', D-02 latest version per ticket, D-03 ordering, D-04 scoping, D-05 cap, D-07 envelope) + - app/api/mobile/tickets/route.ts (cursor seek predicate pattern, LIMIT n+1 trick, snake_case→camelCase mapping) + - app/api/analyzer/tickets/route.ts (lines 319-328 — LEFT JOIN LATERAL pattern for latest version per ticket) + - migrations/069_create_analyzer_tables.sql (lines 11-65 — column types and indexes; `idx_analyzer_analyses_ticket_version` on (ticket_number, analysis_version DESC) supports the LATERAL) + + + - Test: cursor=null + no rows → returns `{analyses: [], nextCursor: null, hasMore: false}` + - Test: more than 25 latest-completed analyses exist → returns 25 rows + non-null nextCursor + hasMore=true + - Test: passing the returned nextCursor → returns the next 25 (older) rows + correct hasMore + - Test: malformed cursor (random string) → returns first page (decodeCursor returns null, no exception) + - Test: a ticket re-analyzed 3 times → appears once in the feed (the highest analysis_version among status='complete' rows) + - Test: kiosk_settings excludes a company → analyses for tickets in that company do NOT appear + - Test: requireAuth fails → 401 (existing behavior from Task 1 gate) + - Test: rows with completed_at IS NULL appear AT THE END (NULLS LAST), tied rows broken by id DESC + + +Replace the `// TODO Task 2` block in `app/api/mobile/analyzer/feed/route.ts` with the complete query implementation. This task does NOT add any new exports or change the file structure — only fills in the GET handler body. + +1. **Apply the kiosk_settings scope.** Just before building the SQL, call: +```typescript +const { condition: companyCondition } = await getMobileCompanyFilter(); +``` +The helper returns `condition` like `c.company_category_id IN (1) AND c.id NOT IN (42, 99)` (or `c.company_category_id = 1` fallback). The helper aliases the companies table as `c` — your SQL must alias `companies` as `c` to match (D-04). This is the security boundary for ANL-01: a manager must not see analyses for tickets in companies outside their kiosk scope. + +2. **Build the predicate list.** Mirror the `conditions: string[]` + `params: unknown[]` pattern from `app/api/mobile/tickets/route.ts` lines 125–166: +```typescript +const conditions: string[] = [ + "aa.status = 'complete'", // D-01 + 't.is_deleted = false', // hide soft-deleted tickets + companyCondition, // D-04 (kiosk_settings scoping) +]; +const params: unknown[] = []; +``` + +3. **Cursor seek predicate (D-03, D-06).** When `cursor` is non-null, append the keyset predicate `(completed_at, id) < (cursor.completed_at, cursor.id)`: +```typescript +if (cursor) { + params.push(cursor.completed_at); + params.push(cursor.id); + conditions.push(`(aa.completed_at, aa.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`); +} +``` +The cast `$N::uuid` is critical because `analyzer_analyses.id` is UUID (not int like tickets.id). + +4. **The query — analyses-first, with LATERAL latest-per-ticket guard (D-02, D-03).** The shape: from `analyzer_analyses` rows, only include the row if it IS the latest `analysis_version` for that `ticket_number` among `status='complete'` rows. This naturally produces "one row per ticket, latest first": +```sql +WITH latest_per_ticket AS ( + SELECT DISTINCT ON (ticket_number) id + FROM analyzer_analyses + WHERE status = 'complete' + ORDER BY ticket_number, analysis_version DESC +) +SELECT aa.id, aa.ticket_number, aa.completed_at, aa.analysis_version, + aa.summary, aa.confidence_score, aa.needs_human_review, + aa.haiku_used, aa.sonnet_used, aa.opus_used, + t.title, + c.company_name + FROM analyzer_analyses aa + INNER JOIN latest_per_ticket l ON l.id = aa.id + INNER JOIN tickets t ON t.ticket_number = aa.ticket_number AND t.is_deleted = false + INNER JOIN companies c ON c.id = t.company_id + WHERE ${conditions.filter excluding the t.is_deleted and aa.status which are now in the CTE/inner join — keep only companyCondition + cursor predicate} + ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC + LIMIT ${limit + 1} +``` + +Practical implementation: keep `companyCondition` and the cursor predicate in the WHERE; absorb `aa.status='complete'` into the CTE and `t.is_deleted=false` into the JOIN. Final WHERE has 1–2 predicates. Use `LIMIT ${limit + 1}` so you can detect `hasMore` without a COUNT (mirrors tickets route line 183). + +Build the final SQL string by interpolating `${conditions.join(' AND ')}` and `${limit + 1}`. Pass `params` to `postgresClient.query(sql, params)`. + +5. **Transform rows snake_case → camelCase** (CLAUDE.md: manual transform, no ORM). Map each pg row to `AnalyzerFeedRow`: +```typescript +const rows = result.rows; +const hasMore = rows.length > limit; +const sliced = hasMore ? rows.slice(0, limit) : rows; + +const analyses: AnalyzerFeedRow[] = sliced.map(row => ({ + id: String(row.id), + ticketNumber: row.ticket_number, + title: row.title ?? '', + companyName: row.company_name ?? '', + summary: row.summary ?? null, + confidenceScore: row.confidence_score === null ? null : Number(row.confidence_score), + haikuUsed: row.haiku_used, + sonnetUsed: row.sonnet_used, + opusUsed: row.opus_used, + needsHumanReview: row.needs_human_review, + completedAt: row.completed_at instanceof Date ? row.completed_at.toISOString() : String(row.completed_at), + analysisVersion: row.analysis_version, +})); +``` +`confidence_score` comes back from pg as a string (NUMERIC type) → coerce with `Number()`. `completed_at` is a `Date` from pg → `.toISOString()`. (Compare to `app/api/analyzer/tickets/route.ts:362` for the same `.toISOString()` pattern.) + +6. **Compute nextCursor from the LAST row** of `sliced` (D-06): +```typescript +const nextCursor = hasMore && analyses.length > 0 + ? encodeCursor({ + completed_at: analyses[analyses.length - 1].completedAt, + id: analyses[analyses.length - 1].id, + }) + : null; +``` +Note the cursor's `completed_at` is the ISO string already in `analyses[N].completedAt` — consistent with the WHERE predicate's `::timestamptz` cast. + +7. **Return** with `satisfies AnalyzerFeedResponse`: +```typescript +return NextResponse.json({ analyses, nextCursor, hasMore } satisfies AnalyzerFeedResponse); +``` + +8. **Security review (per ``):** + - cursor injection → mitigated by `decodeCursor` try/catch + shape validation (returns null on malformed input) + - kiosk scoping → mitigated by `getMobileCompanyFilter()` companyCondition (T-06-02 in threat model) + - payload leakage → only the columns the row card needs are returned; NOT `model_traces`, NOT `human_review_reasons`, NOT `itglue_docs_referenced`, NOT IT Glue doc bodies (T-06-05) + - rate limiting → server-side `Math.min(25, ...)` cap (T-06-04) + + + npx tsc --noEmit --pretty 2>&1 | (! grep -E "app/api/mobile/analyzer/feed") + + + - `npx tsc --noEmit --pretty` exits 0 (no errors) + - SQL contains the latest-per-ticket CTE: `grep -E 'DISTINCT ON \(ticket_number\)' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - SQL filters status complete only: `grep -E "status\s*=\s*'complete'" app/api/mobile/analyzer/feed/route.ts` returns at least one match + - Ordering matches D-03: `grep -F "ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC" app/api/mobile/analyzer/feed/route.ts` returns at least one match + - Cursor seek predicate uses correct types: `grep -E '\\(aa\\.completed_at, aa\\.id\\) < \\(\\$' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - kiosk scoping wired: `grep -F 'getMobileCompanyFilter()' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - LIMIT n+1 trick used: `grep -E 'LIMIT \\$\\{limit \\+ 1\\}' app/api/mobile/analyzer/feed/route.ts` returns at least one match (or equivalent pattern) + - hasMore detection: `grep -E 'rows\\.length > limit' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - camelCase transform present: `grep -E 'ticketNumber: row\\.ticket_number' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - Companies table aliased as `c`: `grep -E 'INNER JOIN companies c\\b' app/api/mobile/analyzer/feed/route.ts` returns at least one match (matches helper's expectation) + - Tickets joined: `grep -E 'INNER JOIN tickets t\\b' app/api/mobile/analyzer/feed/route.ts` returns at least one match + - Sensitive columns NOT selected: `grep -E "(model_traces|itglue_docs_referenced|human_review_reasons)" app/api/mobile/analyzer/feed/route.ts` returns ZERO matches (security: payload minimization) + - Manual smoke test: `curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: "` returns JSON with `analyses` array (or 401 if not authed) — NOT a 500. (Optional; auth-gated, dev-only.) + + + Endpoint returns the latest-completed analysis per ticket, ordered by `completed_at DESC, id DESC`, scoped by `kiosk_settings`, paginated with cursor (≤ 25/page). The response envelope matches `AnalyzerFeedResponse`. Plan 06-02 can fetch and render real data from this route. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client → API (`/api/mobile/analyzer/feed`) | Untrusted query string (cursor, limit) crosses into server; session cookie verified | +| API → Postgres | Parameterized queries; no string interpolation of user input | +| API → response payload | Server controls which columns leave the trust boundary; potentially-sensitive analyzer fields must NOT cross | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-01 | Spoofing / Auth Bypass | `GET /api/mobile/analyzer/feed` | mitigate | Call `requireAuth()` from `lib/auth-utils.ts` BEFORE any DB query (Task 1, line `const { error: authError } = await requireAuth(); if (authError) return authError;`). Better Auth session cookie is the gate; no bypass path. ASVS L1 §V2.1. | +| T-06-02 | Information Disclosure (IDOR / cross-tenant read) | feed route SQL | mitigate | Apply `getMobileCompanyFilter()` `companyCondition` to the WHERE clause (Task 2). Without it a manager could list analyses for tickets in companies outside their kiosk scope. The helper reads `kiosk_settings.mobile_company_category_ids` and `mobile_excluded_company_ids` and emits a SQL fragment scoped to `c.*`. The companies table alias `c` MUST be used in JOIN to match the helper. | +| T-06-03 | Tampering (cursor injection) | `decodeCursor()` | mitigate | Wrap `JSON.parse(Buffer.from(raw,'base64').toString('utf8'))` in try/catch. Shape-validate decoded object: only return non-null when `completed_at` is a string AND `id` is a string. Any malformed input returns `null` → handler treats as "first page". Failure mode is fail-closed (no SQL injection vector — params are still parameterized; worst case is a cursor that doesn't match any row, returning empty). | +| T-06-04 | Denial of Service (large pagination) | feed route limit param | mitigate | Server-side `Math.min(25, Math.max(1, ...))` cap on `limit` query param (Task 1). Even if a client sends `?limit=10000`, the server reads at most 26 rows (`limit + 1` for hasMore detection). LIMIT in SQL is integer-interpolated AFTER the cap. | +| T-06-05 | Information Disclosure (sensitive analyzer payload leakage) | feed route SELECT list | mitigate | Whitelist columns in the SELECT — only the 12 fields `AnalyzerFeedRow` declares. Do NOT select `model_traces`, `itglue_docs_referenced`, `human_review_reasons`, or `error_message`. These can contain client data, IT Glue references, and IT Glue doc bodies (per migration 069 comments). The detail page (Plan 06-03) reuses the existing `/api/analyzer/analyses/[id]` endpoint which is already auth-gated, but its IDOR posture is OUT OF SCOPE for this plan and is flagged in Plan 06-03's threat model. | +| T-06-06 | Repudiation | feed route logging | accept | Auth gate logs are produced by Better Auth middleware; per-request access audit logging is NOT implemented for `/api/mobile/*` today (Phase 4 didn't add it either). Risk is low: read-only endpoint, no state change. Future phase can add structured access logs if compliance requires. | +| T-06-07 | Information Disclosure (SQL error messages) | error catch block | mitigate | The catch block returns `error instanceof Error ? error.message : 'unknown'`. Postgres error messages can include schema details. For a read-only endpoint with parameterized SQL the leakage surface is small (no user-controlled SQL fragments); the team's existing `/api/mobile/tickets` route uses the same pattern, so this matches established convention. Error is also logged to `console.error` for server-side observability. | + + + +- `npx tsc --noEmit --pretty` exits 0 +- `grep -RE "from\s+['\"]@/app/api/mobile/analyzer/feed/route['\"]" app/ components/ 2>/dev/null` returns nothing yet (Wave 2 will create the consumer) +- Manual smoke (developer): `curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: better-auth.session_token="` returns `{analyses: [...], nextCursor, hasMore}` JSON +- Pagination smoke: capture `nextCursor` from response 1, pass as `?cursor=`, verify response 2 returns OLDER rows (or empty if total < 25) + + + +1. `app/api/mobile/analyzer/feed/route.ts` exists and exports `GET`, `AnalyzerFeedRow`, `AnalyzerFeedResponse` +2. The endpoint returns ONE row per ticket (latest analysis_version among status='complete' rows) — NOT multiple rows for re-analyzed tickets +3. Ordering is `completed_at DESC NULLS LAST, id DESC` +4. Out-of-scope companies (per `kiosk_settings`) are excluded +5. Cursor pagination works: passing the returned `nextCursor` returns the next page; null `nextCursor` means exhausted +6. Server-side limit cap of 25 is enforced regardless of `?limit=` value +7. Unauthenticated requests return 401 (via `requireAuth()`) +8. Response payload contains ONLY the 12 fields declared by `AnalyzerFeedRow` (no `model_traces`, no IT Glue bodies, no `human_review_reasons` array) +9. `npx tsc --noEmit --pretty` passes + + + +After completion, create `.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md` documenting: +- The exported types (with their final field list) +- The SQL approach (DISTINCT ON CTE + JOIN, ordering, cursor predicate) +- How `kiosk_settings` scoping is applied (companies aliased as `c`) +- Notes for Plan 06-02 executors: import path is `@/app/api/mobile/analyzer/feed/route`; sample request URL is `/api/mobile/analyzer/feed?limit=25` + + + \ No newline at end of file diff --git a/.planning/phases/06-analyzer-feed-new/06-02-PLAN.md b/.planning/phases/06-analyzer-feed-new/06-02-PLAN.md new file mode 100644 index 0000000..73ad6f2 --- /dev/null +++ b/.planning/phases/06-analyzer-feed-new/06-02-PLAN.md @@ -0,0 +1,696 @@ +--- +phase: 06-analyzer-feed-new +plan: 02 +type: execute +wave: 2 +depends_on: [06-01] +files_modified: + - components/mobile/AnalyzerStagePips.tsx + - components/mobile/ConfidenceBadge.tsx + - components/mobile/AnalyzerRowSkeleton.tsx + - components/mobile/AnalyzerFeedRow.tsx + - app/mobile/analyzer/page.tsx +autonomous: true +requirements: [ANL-01, ANL-02, ANL-05, ANL-06] +must_haves: + truths: + - "Tapping the Analyzer tab in the bottom nav lands on /mobile/analyzer and shows a most-recent-first list of completed AI ticket analyses (ANL-01)" + - "Each row shows ticket number, title, analyzer one-line summary, confidence badge, and stage indicator (Triage/Analyze/Deep Review pips) (ANL-02)" + - "Tapping a row navigates to /mobile/analyzer/[id]" + - "Scrolling near the bottom auto-loads the next page (~25 rows) via IntersectionObserver" + - "A focusable Load more button is always present when hasMore is true (accessibility fallback)" + - "Initial load renders 5 skeleton rows; subsequent fetches show inline spinner above Load more button" + - "When the feed is empty, an empty state with 'No analyses yet' renders with link to desktop" + - "Read-only — NO edit, re-run, or prompt-tuning controls (ANL-05)" + artifacts: + - path: "components/mobile/AnalyzerStagePips.tsx" + provides: "3-dot stage indicator (haiku/sonnet/opus filled or muted)" + exports: ["AnalyzerStagePips"] + min_lines: 25 + - path: "components/mobile/ConfidenceBadge.tsx" + provides: "Bucketed confidence label (High/Medium/Low) with color tones" + exports: ["ConfidenceBadge"] + min_lines: 25 + - path: "components/mobile/AnalyzerRowSkeleton.tsx" + provides: "Skeleton placeholder matching row shape (no priority stripe)" + exports: ["AnalyzerRowSkeleton"] + min_lines: 15 + - path: "components/mobile/AnalyzerFeedRow.tsx" + provides: "Card-wrapped row with header/title/summary/footer linked to detail page" + exports: ["AnalyzerFeedRow"] + min_lines: 50 + - path: "app/mobile/analyzer/page.tsx" + provides: "Feed list page with IntersectionObserver, Load more, error/empty states" + min_lines: 120 + key_links: + - from: "app/mobile/analyzer/page.tsx" + to: "/api/mobile/analyzer/feed" + via: "fetch in useEffect + Load more handler" + pattern: "fetch.*api/mobile/analyzer/feed" + - from: "app/mobile/analyzer/page.tsx" + to: "AnalyzerFeedRow component" + via: "import + map over analyses array" + pattern: " +Replace the placeholder `app/mobile/analyzer/page.tsx` (Phase 2 stub) with the real read-only Analyzer feed: a most-recent-first list of completed AI ticket analyses with cursor-based infinite scroll, identical UX patterns to Phase 4 Tickets. Build the four supporting `components/mobile/*` components the row card depends on. + +Purpose: ANL-01 + ANL-02 + ANL-05 + ANL-06 — this is the manager-facing surface. It must FEEL like Phase 4 (same skeleton-then-rows initial load, same IntersectionObserver, same Load more fallback) so there's zero learning curve when switching between Tickets and Analyzer tabs. + +Output: +- 4 new components in `components/mobile/`: stage pips, confidence badge, row skeleton, feed row card +- Replaced page at `app/mobile/analyzer/page.tsx` (the placeholder is gone; the real feed is in) + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/06-analyzer-feed-new/06-CONTEXT.md +@.planning/phases/06-analyzer-feed-new/06-UI-SPEC.md +@.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md +@CLAUDE.md +@app/mobile/tickets/page.tsx +@app/mobile/analyzer/page.tsx +@components/mobile/TicketRowSkeleton.tsx +@components/mobile/FinanceRow.tsx +@components/ui/card.tsx +@components/ui/badge.tsx +@components/ui/skeleton.tsx +@components/ui/empty-state.tsx +@app/api/mobile/analyzer/feed/route.ts + + + + +From `@/app/api/mobile/analyzer/feed/route` (Plan 06-01 export): +```typescript +export interface AnalyzerFeedRow { + id: string; + ticketNumber: string; + title: string; + companyName: string; + summary: string | null; + confidenceScore: number | null; + haikuUsed: boolean; + sonnetUsed: boolean; + opusUsed: boolean; + needsHumanReview: boolean; + completedAt: string; + analysisVersion: number; +} +export interface AnalyzerFeedResponse { + analyses: AnalyzerFeedRow[]; + nextCursor: string | null; + hasMore: boolean; +} +``` +NOTE: The component file is also named `AnalyzerFeedRow.tsx`. The TypeScript interface and the React component share a name — disambiguate by importing the type with `import type` and the component with regular import. (Phase 4 does the exact same pattern: `MobileTicket` type vs `` component.) + +From `app/mobile/tickets/page.tsx` (PATTERN SOURCE — copy structure): +- `relTime(ts: string | null): string` helper at lines 22-30 (60s→`Xm ago`, hours→`Xh ago`, else `Xd ago`) +- `Suspense` wrapper around the inner client component (Next.js 16 useSearchParams requirement — but Analyzer feed has no URL params this phase, so Suspense may not be needed; verify during build) +- `useState`, `useEffect`, `useCallback`, `useRef`, `IntersectionObserver` setup at lines 188-203 +- `loadFirst` and `loadMore` callback pattern at lines 121-174 +- Load more fallback button at lines 292-304 (`w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50`) +- Inline loading spinner at lines 285-289 (`Loader2 w-4 h-4 animate-spin text-muted-foreground`) + +From `components/mobile/TicketRowSkeleton.tsx` (PATTERN — adapt for analyzer row shape): +```tsx +'use client'; +import { Skeleton } from '@/components/ui/skeleton'; +export function TicketRowSkeleton() { + return ( +
+ + ... +
+ ); +} +``` +Per D-11 the analyzer row has NO `border-l-4` — drop the stripe in `AnalyzerRowSkeleton`. +
+
+ + + + + Task 1: Build the 4 presentational components in components/mobile/ + components/mobile/AnalyzerStagePips.tsx, components/mobile/ConfidenceBadge.tsx, components/mobile/AnalyzerRowSkeleton.tsx, components/mobile/AnalyzerFeedRow.tsx + + - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-32, D-33 — locked decisions) + - .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md §"Component Inventory" (exact JSX shapes, classnames, copy strings) + - components/mobile/TicketRowSkeleton.tsx (skeleton pattern) + - components/mobile/FinanceRow.tsx (PascalCase export, props interface, presentational pattern) + - components/ui/card.tsx (Card + CardContent — Card wraps with `bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm` by default; override with px-4 py-4 and reset gap) + - components/ui/badge.tsx (Badge component with variant prop) + - app/api/mobile/analyzer/feed/route.ts (AnalyzerFeedRow type — `import type`) + + +Build four small presentational components in `components/mobile/`. All four are `'use client'` (per CLAUDE.md mobile pattern). All four use PascalCase exports from PascalCase filenames (matches existing convention in `components/mobile/`: TicketRowSkeleton, FinanceRow, KpiCardMobile). + +**FILE A — `components/mobile/AnalyzerStagePips.tsx` (D-13, D-14)** + +Three colored dots representing pipeline stages reached. Pure CSS, no animation, no library. The visually-hidden span describes stages used for screen readers. Exact JSX: + +```tsx +'use client'; + +/* AnalyzerStagePips — phase 06 (ANL-02). + * Purpose: 3-dot stage indicator (Triage → Analyze → Deep Review) with caret separators. + * Filled when stage was used, muted when not. Per D-13/D-14 (no animation). + * Props: see AnalyzerStagePipsProps. */ + +export interface AnalyzerStagePipsProps { + haikuUsed: boolean; + sonnetUsed: boolean; + opusUsed: boolean; +} + +const STAGE_LABELS = ['Triage', 'Analyze', 'Deep Review']; + +export function AnalyzerStagePips({ haikuUsed, sonnetUsed, opusUsed }: AnalyzerStagePipsProps) { + const used = [haikuUsed, sonnetUsed, opusUsed]; + const completed = STAGE_LABELS.filter((_, i) => used[i]); + const srLabel = completed.length === 0 + ? 'No stages completed' + : `Stages completed: ${completed.join(', ')}`; + return ( +
+ {srLabel} + {used.map((isUsed, i) => ( + + + ))} +
+ ); +} +``` + +Verify against UI-SPEC §"Stage Pips": dot size `h-1.5 w-1.5`, container gap `gap-1.5`, filled `bg-primary`, muted `bg-muted-foreground/30`, caret `›` between pips with `text-[10px] text-muted-foreground`. NO tooltip, NO hover, NO animation (D-13). + +**FILE B — `components/mobile/ConfidenceBadge.tsx` (D-15, D-16)** + +shadcn Badge with bucketed background and text color. Renders `null` when score is null (D-15). Exact JSX: + +```tsx +'use client'; + +/* ConfidenceBadge — phase 06 (ANL-02). + * Purpose: Bucketed confidence label — High (>=0.85) / Medium (>=0.65) / Low (<0.65). + * Renders nothing when score is null. Per D-15 / D-16. + * Props: see ConfidenceBadgeProps. */ + +import { Badge } from '@/components/ui/badge'; + +export interface ConfidenceBadgeProps { + score: number | null; +} + +export function ConfidenceBadge({ score }: ConfidenceBadgeProps) { + if (score === null) return null; + + let label: string; + let className: string; + if (score >= 0.85) { + label = 'High'; + className = 'bg-green-500/10 text-green-700 dark:text-green-400'; + } else if (score >= 0.65) { + label = 'Medium'; + className = 'bg-amber-500/10 text-amber-700 dark:text-amber-400'; + } else { + label = 'Low'; + className = 'bg-slate-500/10 text-slate-600 dark:text-slate-400'; + } + + return ( + + {label} + + ); +} +``` + +Verify thresholds against UI-SPEC §"Confidence Badge Colors" + D-15: `>= 0.85` High green, `0.65 <= < 0.85` Medium amber, `< 0.65` Low slate, `null` → no element. Tailwind classes are EXACTLY `bg-green-500/10 text-green-700 dark:text-green-400` etc. — no other shades. `border-0` removes the default outline border (D-16). `text-[10px] px-1.5 py-0.5` exact. + +**FILE C — `components/mobile/AnalyzerRowSkeleton.tsx` (D-28)** + +Mirror `TicketRowSkeleton` but DROP the `border-l-4 border-muted` stripe (per D-11 analyzer rows have no priority stripe). Use a Card wrapper to match the real row's surface. + +```tsx +'use client'; + +/* AnalyzerRowSkeleton — phase 06 (D-28). + * Purpose: Skeleton placeholder matching analyzer feed row shape (no priority stripe per D-11). + * Renders 5 instances on initial load. + * Props: none — purely presentational. */ + +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function AnalyzerRowSkeleton() { + return ( + + +
+ + +
+ + + +
+ + +
+
+
+ ); +} +``` + +Note `py-0 gap-0` overrides the default Card `py-6 gap-6` so internal padding comes from `CardContent`. Verify shape against UI-SPEC §"Skeleton Row". + +**FILE D — `components/mobile/AnalyzerFeedRow.tsx` (D-10, D-11, D-12, D-17)** + +The feed row Card. Per D-12 the entire Card is a `` to `/mobile/analyzer/[id]`. Per D-11 NO `border-l-4`. Per D-10 four-line layout: header / title / summary / footer. + +```tsx +'use client'; + +/* AnalyzerFeedRow — phase 06 (ANL-02). + * Purpose: Feed row card — header (ticket# + time-ago), title (1-line truncate), + * summary (2-line clamp), footer (stage pips left + confidence/review right). + * Entire card is a Link to /mobile/analyzer/[id]. Per D-10..D-12, D-17. + * Props: AnalyzerFeedRow type from @/app/api/mobile/analyzer/feed/route. */ + +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { AnalyzerStagePips } from '@/components/mobile/AnalyzerStagePips'; +import { ConfidenceBadge } from '@/components/mobile/ConfidenceBadge'; +import type { AnalyzerFeedRow as AnalyzerFeedRowType } from '@/app/api/mobile/analyzer/feed/route'; + +function relTime(ts: string | null): string { + if (!ts) return '—'; + const diff = Date.now() - new Date(ts).getTime(); + const m = Math.floor(diff / 60000); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +export interface AnalyzerFeedRowProps { + row: AnalyzerFeedRowType; +} + +export function AnalyzerFeedRow({ row }: AnalyzerFeedRowProps) { + return ( + + + + {/* Line 1 — header row */} +
+ + {row.ticketNumber} + + + {relTime(row.completedAt)} + +
+ + {/* Line 2 — title */} +

+ {row.title} +

+ + {/* Line 3 — summary clamp (2 lines, fallback "—") */} +

+ {row.summary ?? '—'} +

+ + {/* Footer — pips left, badges right */} +
+ +
+ + {row.needsHumanReview && ( + + Review + + )} +
+
+
+
+ + ); +} +``` + +Verify against UI-SPEC §"Feed Row Card": title `text-sm font-semibold leading-snug truncate` (1-line), summary `text-xs text-muted-foreground line-clamp-2`, ticket# badge `bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono`, time-ago `text-[10px] text-muted-foreground`, footer flex row, Review pill EXACTLY `bg-destructive/10 text-destructive` with copy "Review" (D-17). NO icons in the Review pill, NO exclamation mark. + +The `relTime()` helper is duplicated inline (per D-04 and CONTEXT.md "duplicate inline; extract shared only when third caller appears"). The tickets page is the second caller; analyzer row is the third — but the diff is small enough that inline duplication keeps Plan 06-02 atomic. A future cleanup phase can extract. +
+ + npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)\\.tsx" || echo "TypeScript clean for new components" + + + - All 4 files exist: `for f in components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx; do test -f "$f" || echo "MISSING $f"; done` produces no MISSING output + - Each file starts with `'use client';`: `head -1 components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx | grep -c "'use client'"` returns 4 + - Each file exports its named component: `grep -E "^export function (AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)" components/mobile/Analyzer*.tsx components/mobile/ConfidenceBadge.tsx | wc -l` returns 4 + - AnalyzerStagePips renders correct dot classes: `grep -F 'bg-primary' components/mobile/AnalyzerStagePips.tsx` returns at least one match AND `grep -F 'bg-muted-foreground/30' components/mobile/AnalyzerStagePips.tsx` returns at least one match + - AnalyzerStagePips dot size correct: `grep -F 'h-1.5 w-1.5' components/mobile/AnalyzerStagePips.tsx` returns at least one match + - AnalyzerStagePips includes sr-only label: `grep -F 'sr-only' components/mobile/AnalyzerStagePips.tsx` returns at least one match + - ConfidenceBadge thresholds exact (D-15): `grep -F '0.85' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F '0.65' components/mobile/ConfidenceBadge.tsx` returns at least one match + - ConfidenceBadge tones exact: `grep -F 'bg-green-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F 'bg-amber-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F 'bg-slate-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match + - ConfidenceBadge dark-mode tones present: `grep -F 'dark:text-green-400' components/mobile/ConfidenceBadge.tsx` returns at least one match + - ConfidenceBadge returns null on null score: `grep -E 'score === null.*return null' components/mobile/ConfidenceBadge.tsx` returns at least one match (or equivalent early-return) + - AnalyzerFeedRow links to detail: `grep -F 'href={`/mobile/analyzer/${row.id}`}' components/mobile/AnalyzerFeedRow.tsx` returns at least one match (or use `grep -E 'mobile/analyzer/' components/mobile/AnalyzerFeedRow.tsx`) + - AnalyzerFeedRow has NO border-l-4: `grep -F 'border-l-4' components/mobile/AnalyzerFeedRow.tsx components/mobile/AnalyzerRowSkeleton.tsx` returns ZERO matches (D-11 — no priority stripe) + - Title row classes exact: `grep -F 'text-sm font-semibold leading-snug truncate' components/mobile/AnalyzerFeedRow.tsx` returns at least one match + - Summary clamp class: `grep -F 'line-clamp-2' components/mobile/AnalyzerFeedRow.tsx` returns at least one match + - Summary fallback character is em-dash: `grep -F "'—'" components/mobile/AnalyzerFeedRow.tsx` returns at least one match (literal em-dash, not "--") + - Review pill copy exact: `grep -E '>Review<' components/mobile/AnalyzerFeedRow.tsx` returns at least one match + - Review pill tone: `grep -F 'bg-destructive/10 text-destructive' components/mobile/AnalyzerFeedRow.tsx` returns at least one match + - Skeleton renders no border-l-4 (already covered by previous check) + - AnalyzerFeedRow imports the type, not the component, from the route file: `grep -E 'import type \\{ AnalyzerFeedRow.*from .@/app/api/mobile/analyzer/feed/route.' components/mobile/AnalyzerFeedRow.tsx` returns at least one match + - `npx tsc --noEmit --pretty` exits 0 + + + Four components compile, exported with correct props interfaces, render the exact Tailwind classes and copy strings declared in 06-UI-SPEC. Task 2 can compose them in the page. + +
+ + + Task 2: Replace placeholder with the feed list page (fetch, IntersectionObserver, Load more, empty/error states) + app/mobile/analyzer/page.tsx + + - app/mobile/analyzer/page.tsx (current placeholder — being replaced wholesale) + - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-08, D-09, D-28, D-29, D-30, D-31, D-34, D-35, D-38) + - .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md §"Infinite Scroll Sentinel + Load More" + §"Copywriting Contract" + §"Loading States" + - app/mobile/tickets/page.tsx (PATTERN SOURCE — copy the IntersectionObserver setup at lines 188-203, the loadFirst/loadMore pattern at lines 121-174, the Load more button at lines 292-304, the inline spinner at lines 285-289) + - components/ui/empty-state.tsx (EmptyState component — has `icon`, `title`, `description`, `action` props) + - app/api/mobile/analyzer/feed/route.ts (the endpoint Plan 06-01 created) + + +**Delete the existing placeholder content** at `app/mobile/analyzer/page.tsx` and replace with the real feed page. The file is being rewritten end-to-end; no part of the placeholder JSX/imports survives. + +The page is `'use client'` (CLAUDE.md mobile pattern; D-38 — no SWR, no react-query, plain `useState` + `useEffect` + `fetch`). It does NOT need `Suspense` because it has NO `useSearchParams()` calls (no URL filter sync this phase per CONTEXT.md "feed has no filters this phase"). If TypeScript or runtime requires Suspense for some other reason, wrap as in `app/mobile/tickets/page.tsx` lines 73-79. + +Implementation: + +```tsx +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; +import Link from 'next/link'; +import { Loader2, Sparkles, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; +import { AnalyzerFeedRow } from '@/components/mobile/AnalyzerFeedRow'; +import { AnalyzerRowSkeleton } from '@/components/mobile/AnalyzerRowSkeleton'; +import type { AnalyzerFeedRow as AnalyzerFeedRowType, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'; + +export default function MobileAnalyzerPage() { + // List state + const [analyses, setAnalyses] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + // First page (mount) + const loadFirst = useCallback(async () => { + setLoading(true); + setError(null); + try { + const r = await fetch('/api/mobile/analyzer/feed?limit=25'); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data: AnalyzerFeedResponse = await r.json(); + setAnalyses(data.analyses); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to load analyses'; + setError(msg); + toast.error('Failed to load analyses'); + } finally { + setLoading(false); + } + }, []); + + // Cursor advance + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore || !nextCursor) return; + setLoadingMore(true); + setError(null); + try { + const sp = new URLSearchParams({ cursor: nextCursor, limit: '25' }); + const r = await fetch(`/api/mobile/analyzer/feed?${sp.toString()}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data: AnalyzerFeedResponse = await r.json(); + setAnalyses(prev => [...prev, ...data.analyses]); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to load more analyses'; + setError(msg); + toast.error('Failed to load more analyses'); + } finally { + setLoadingMore(false); + } + }, [loadingMore, hasMore, nextCursor]); + + useEffect(() => { void loadFirst(); }, [loadFirst]); + + // IntersectionObserver — D-08 (rootMargin: '200px', no-op when loadingMore || !hasMore) + const sentinelRef = useRef(null); + useEffect(() => { + const node = sentinelRef.current; + if (!node) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) { + void loadMore(); + } + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [hasMore, loadingMore, loading, loadMore]); + + // ──── Render ──── + return ( +
+ {/* Page H1 — D-35 (in body, not in shell HeaderBar) */} +

Analyzer

+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : analyses.length === 0 ? ( + // Empty state — D-31 +
+
+ + + +

No analyses yet

+

+ Completed AI ticket analyses will appear here. +

+ + Open desktop Analyzer + + +
+
+ ) : ( + <> +
+ {analyses.map((row) => ( + + ))} +
+ + {/* Sentinel — D-08 */} + + ); +} +``` + +**Copy contract — these strings are LOCKED in 06-UI-SPEC §"Copywriting Contract":** +- Page H1: exactly `Analyzer` (D-35) +- Empty state heading: exactly `No analyses yet` (D-31) +- Empty state body: exactly `Completed AI ticket analyses will appear here.` (D-31) +- Empty state CTA visible label: exactly `Open desktop Analyzer` linking to `/analyzer/tickets` (D-31) +- Load more button (idle): exactly `Load more` +- Load more button (loading): exactly `Loading…` (with ellipsis character, not three dots) +- Load more button (error): exactly `Retry` +- Error toast (initial load): exactly `Failed to load analyses` +- Error toast (load more): exactly `Failed to load more analyses` + +**Container per D-34:** Outer `
` matches the page-level rhythm; rows in `
` per UI-SPEC §"Feed Row Card" row list container. + +**Read-only (ANL-05, D-23):** This page renders NO Buttons that suggest re-running, editing, or prompt-tuning. Only the Load more fallback button (which is a pagination control, not an analysis action) and the empty-state link to desktop. NO ` + + {analysis ? `Analyzer / #${analysis.ticketNumber}` : ''} + + + +
+ ); + + // ──── Loading skeleton (D-21 / UI-SPEC "Detail page loading") ──── + if (loading) { + return ( +
+ {header} +
+ + +
+ + +
+
+ {[0, 1, 2].map((i) => ( +
+ + + + +
+ ))} +
+ ); + } + + // ──── Error state (404 or fetch failure) ──── + if (error || !analysis) { + return ( +
+ {header} +
+

{error ?? 'Analysis not found'}

+
+
+ ); + } + + // ──── Loaded — full render ──── + return ( +
+ {header} + + {/* Identity block (D-20 — adjusted: no title/company per interfaces note) */} +
+ + {analysis.ticketNumber} + +

+ {analysis.completedAt ? relTime(analysis.completedAt) : '—'} +

+
+ + + {analysis.needsHumanReview && ( + + Review + + )} +
+
+ + + + {/* Section 1 — Summary (D-21) */} +
+

Summary

+ {analysis.summary ? ( +

+ {analysis.summary} +

+ ) : ( +

Summary not available.

+ )} +
+ + + + {/* Section 2 — Next Step (D-21) */} +
+

Next Step

+ {analysis.nextStep ? ( +

+ {analysis.nextStep} +

+ ) : ( +

Next step not available.

+ )} +
+ + + + {/* Section 3 — Next Step Rationale (D-21) */} +
+

Next Step Rationale

+ {analysis.nextStepRationale ? ( +

+ {analysis.nextStepRationale} +

+ ) : ( +

Rationale not available.

+ )} +
+ + {/* Footer link (D-22) — "View full analysis" → desktop */} + +
+ ); +} +``` + +**Locked copy (06-UI-SPEC §"Copywriting Contract") — exact strings:** +- Back button visible label: `Analyzer` (with ArrowLeft icon) +- Back button aria-label: `Back to Analyzer` +- Breadcrumb: `Analyzer / #{ticketNumber}` (template literal) +- Header right link aria-label: `Open full analysis on desktop` +- Section headings: `Summary`, `Next Step`, `Next Step Rationale` +- Null fallbacks: `Summary not available.`, `Next step not available.`, `Rationale not available.` (all with trailing period) +- Footer link visible label: `View full analysis` (NOT "View on desktop", NOT "Open analysis") +- Review pill copy: `Review` +- Error toast: `Failed to load analysis` + +**Read-only enforcement (ANL-05, D-23):** This page renders ZERO Buttons that suggest actions. The only interactive elements are: (1) back button → `router.back()`, (2) header external link → desktop, (3) footer external link → desktop. NO Re-run, NO Cancel, NO Edit, NO Share button, NO triple-dot menu. If executor adds one, the plan fails ANL-05. + +**Why no title/company in identity block (per interfaces note):** UI-SPEC §"Detail Page Identity Block" lists title and company name. CONTEXT.md D-25 mandates reuse of `/api/analyzer/analyses/[id]` which returns ONLY `PersistedAnalysis` (no joined ticket title). D-36 prohibits modifying the desktop endpoint. Conflict resolution: omit title/company from identity block — the breadcrumb (`Analyzer / #T20250034`) plus the prominent ticket# badge in the identity block convey ticket identity. Manager who needs full context taps "View full analysis" → desktop. + + + npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/analyzer/\\[id\\]/page\\.tsx" || echo "TypeScript clean for detail page" + + + - File exists: `test -f 'app/mobile/analyzer/[id]/page.tsx'` + - Starts with `'use client';`: `head -1 'app/mobile/analyzer/[id]/page.tsx' | grep -F "'use client'"` returns one match + - Default export present: `grep -E '^export default function MobileAnalyzerDetailPage' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Imports PersistedAnalysis type from existing module: `grep -E "import type.*PersistedAnalysis.*from .@/lib/types/analyzer." 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Imports stage pips component: `grep -E "from .@/components/mobile/AnalyzerStagePips." 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Imports confidence badge component: `grep -E "from .@/components/mobile/ConfidenceBadge." 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Async params unwrap (Next.js 16): `grep -F 'use(params)' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Fetches existing endpoint (D-25): `grep -F '/api/analyzer/analyses/' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Endpoint URL uses encoded id: `grep -F 'encodeURIComponent(id)' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Reads `data.analysis` from response wrapper: `grep -F 'data.analysis' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Back button uses router.back: `grep -F 'router.back()' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Back button aria-label exact: `grep -F 'aria-label="Back to Analyzer"' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Header external link aria-label exact: `grep -F 'aria-label="Open full analysis on desktop"' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Breadcrumb format: `grep -E "Analyzer / #" 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Three section headings exact: `grep -E '>Summary<' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match AND `grep -E '>Next Step<' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match AND `grep -E '>Next Step Rationale<' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Section heading typography: `grep -F 'text-sm font-semibold' 'app/mobile/analyzer/[id]/page.tsx'` returns at least 3 matches (one per heading) + - whitespace-pre-wrap on body: `grep -F 'whitespace-pre-wrap' 'app/mobile/analyzer/[id]/page.tsx'` returns at least 3 matches + - Body typography exact (D-21): `grep -F 'text-sm font-normal leading-relaxed' 'app/mobile/analyzer/[id]/page.tsx'` returns at least 3 matches + - Null fallbacks exact: `grep -F 'Summary not available.' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match AND `grep -F 'Next step not available.' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match AND `grep -F 'Rationale not available.' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Footer link copy exact: `grep -F 'View full analysis' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Footer link target=_blank: `grep -F 'target="_blank"' 'app/mobile/analyzer/[id]/page.tsx'` returns at least 2 matches (header + footer) + - Footer link rel attr: `grep -F 'rel="noopener noreferrer"' 'app/mobile/analyzer/[id]/page.tsx'` returns at least 2 matches + - Footer link points to desktop route: `grep -F '/analyzer/analysis/' 'app/mobile/analyzer/[id]/page.tsx'` returns at least 2 matches + - Footer link touch target: `grep -F 'min-h-[44px]' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Separator used between sections: `grep -F 'Separator' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match (import + at least one render) + - Toast on error: `grep -F "toast.error('Failed to load analysis')" 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match + - Loading skeleton renders before data: `grep -F 'Skeleton' 'app/mobile/analyzer/[id]/page.tsx'` returns at least one match (Skeleton import + JSX) + - NO read-write actions (ANL-05): `grep -E '\\bonClick=.*\\b(reRun|edit|delete|cancel|share|retry)Analysis\\b' 'app/mobile/analyzer/[id]/page.tsx'` returns ZERO matches + - NO modal/dialog imports (D-23 — page is real route, not modal): `grep -E "from\\s+['\\\"]@/components/ui/dialog['\\\"]" 'app/mobile/analyzer/[id]/page.tsx'` returns ZERO matches + - Does NOT modify desktop routes (D-36): `git status --porcelain app/api/analyzer/ app/analyzer/ 2>/dev/null | wc -l` returns 0 after this task + - `npx tsc --noEmit --pretty` exits 0 + - Manual smoke (when dev server running): visit `http://localhost:3100/mobile/analyzer/` in a logged-in browser → see breadcrumb, identity block, three sections, footer link. Tapping back chevron returns to feed. + + + `/mobile/analyzer/[id]` is a real shareable page that fetches the existing analyses endpoint, renders calm Summary / Next Step / Rationale sections with section headings and `whitespace-pre-wrap` body text, identity block with stage pips and confidence badge, header back-chevron + external-link, footer "View full analysis" link to desktop. Read-only — no controls beyond navigation. `npx tsc --noEmit --pretty` passes. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → mobile detail page | The `id` URL segment is user-controllable (anyone can edit the URL bar) | +| Mobile detail page → API (`/api/analyzer/analyses/[id]`) | The `id` is forwarded to the existing detail endpoint without modification | +| API → response payload | The existing endpoint returns the FULL PersistedAnalysis row (including IT Glue refs, model_traces if present) — but the mobile page only RENDERS Summary, Next Step, Rationale, and stage flags. Other fields are received but not displayed. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06P03-01 | Spoofing / Auth Bypass | mobile detail page | mitigate | Page is under `/mobile/*` — `middleware.ts` requires Better Auth session. The fetched API endpoint `/api/analyzer/analyses/[id]` ALSO calls `requireAuth()` server-side (`app/api/analyzer/analyses/[id]/route.ts:16`). Defense in depth. | +| T-06P03-02 | Information Disclosure (IDOR) | `GET /api/analyzer/analyses/:id` | **flag for review** | The existing endpoint authenticates the user but does NOT scope by `kiosk_settings` company filter. A logged-in user could enumerate UUIDs of analyses for tickets in companies outside their kiosk scope. This is an EXISTING risk in the desktop product — Phase 6 inherits it without making it worse. **Recommendation:** post-Phase-6, file a follow-up ticket to add `kiosk_settings` scoping to `app/api/analyzer/analyses/[id]/route.ts` (or specifically to mobile callers). NOT in Phase 6 scope per D-36 (no desktop changes). The risk is mitigated for the typical user (guessing 36-character UUIDs is computationally infeasible) but the IDOR posture is weaker than Plan 06-01's feed endpoint. Disposition is **accept-and-flag** (track in STATE.md as a follow-up); upgrade to **mitigate** if user prioritizes. | +| T-06P03-03 | Information Disclosure (XSS via summary/next_step text) | section body renders | mitigate | All three section bodies render via JSX text interpolation (`{analysis.summary}`) inside `

` tags — React auto-escapes. `whitespace-pre-wrap` is a CSS property and does NOT enable HTML parsing. NO `dangerouslySetInnerHTML` used anywhere. ASVS L1 §V5.3.3. | +| T-06P03-04 | Tampering (id segment manipulation) | URL segment | mitigate | The `id` is URL-encoded with `encodeURIComponent(id)` before being passed to the fetch URL — prevents path-traversal style attacks. Server-side, the existing endpoint is parameter-bound (`WHERE id = $1`); arbitrary input becomes an empty result, not SQL injection. | +| T-06P03-05 | Information Disclosure (404 leaks existence) | error state | accept | When an analysis doesn't exist OR is in a different scope, the endpoint returns 404. This is the existing desktop behavior. The mobile page renders "Analysis not found" — same UX as desktop. Negligible additional risk. | +| T-06P03-06 | Information Disclosure (toast leaks server error) | catch block | mitigate | Toast copy is hardcoded to `Failed to load analysis` — never shows raw `e.message`. ASVS L1 §V7.4.1. | +| T-06P03-07 | Read-only violation | page interactions | mitigate | The plan acceptance criteria includes a grep that fails if any `reRun|edit|delete|cancel|share|retry` Analysis onClick handlers are added (ANL-05 enforcement). NO Dialog imports allowed (would imply edit/confirm UI). Only navigation actions present (router.back + 2 external links). | + + + +- `npx tsc --noEmit --pretty` exits 0 +- `app/mobile/analyzer/[id]/page.tsx` is the only file modified by this plan +- No diff in `app/api/analyzer/`, `app/analyzer/`, `lib/types/`, `lib/services/analyzer/` (per D-36, D-37): `git status --porcelain | grep -E '^(M|A) (app/api/analyzer|app/analyzer|lib/services/analyzer)'` returns nothing +- Manual smoke (developer): authenticate on `http://localhost:3100`, visit `/mobile/analyzer/` → + 1. Detail page renders breadcrumb "Analyzer / #T...", back chevron, and external-link icon in header + 2. Identity block shows ticket# badge + completed-at relative time + stage pips + confidence badge + 3. Three sections (Summary, Next Step, Next Step Rationale) — each with heading + body OR locked fallback copy + 4. Footer has "View full analysis" link with ExternalLink icon → opens `/analyzer/analysis/` in new tab +- Tap back chevron: returns to `/mobile/analyzer` at the same scroll position (browser history) +- Visit `/mobile/analyzer/not-a-real-uuid`: see "Analysis not found" message + header (404 path) +- Confirm BottomNav: Analyzer tab is active (text-primary) on the detail page (Phase 2 startsWith match) + + + +1. `app/mobile/analyzer/[id]/page.tsx` exists and exports a default Page component +2. Page fetches `GET /api/analyzer/analyses/[id]` (existing endpoint reused per D-25, no new endpoint) +3. Page renders three sections in order: Summary, Next Step, Next Step Rationale, each with `text-sm font-semibold` heading and `text-sm font-normal leading-relaxed whitespace-pre-wrap` body +4. Null fields render the locked fallback copy (`Summary not available.` etc.) in muted color +5. Header has: back chevron (`ArrowLeft`) + "Analyzer" label tied to `router.back()`, breadcrumb `Analyzer / #{ticketNumber}`, ExternalLink icon to `/analyzer/analysis/[id]` (opens new tab) +6. Footer has: "View full analysis" link with ExternalLink icon → `/analyzer/analysis/[id]` (opens new tab, `min-h-[44px]` touch target) +7. Identity block renders ticket# badge, completed-at relative time, stage pips, confidence badge, optional Review pill +8. The page is read-only — NO Edit/Re-run/Share/Delete/Cancel buttons (ANL-05) +9. Loading state renders Skeletons for identity + 3 sections; error state renders "Analysis not found" message; toast fires on fetch failure +10. NO modifications to desktop analyzer routes or services (D-36, D-37) +11. `npx tsc --noEmit --pretty` passes + + + +After completion, create `.planning/phases/06-analyzer-feed-new/06-03-SUMMARY.md` documenting: +- The page structure (header, identity, 3 sections, footer) +- The decision to omit title/company from identity block (per interfaces note — D-25/D-36 conflict with UI-SPEC; resolved by following the locked CONTEXT decisions) +- The IDOR follow-up (T-06P03-02 in threat model — flag for STATE.md) +- Confirmation that no desktop files were touched + + + \ No newline at end of file